├── .eslintignore ├── jest.setup.js ├── vercel.json ├── jest.config.js ├── .storybook ├── preview.js └── main.js ├── .gitignore ├── src ├── utils │ └── index.ts ├── index.tsx ├── ListboxLabel.tsx ├── ListboxButton.tsx ├── ListboxOption.tsx ├── ListboxList.tsx └── Listbox.tsx ├── stories ├── utils │ └── cn.ts └── Listbox.stories.tsx ├── tailwind.config.js ├── tsconfig.json ├── .github └── workflows │ └── main.yml ├── LICENSE ├── package.json ├── test ├── index.test.tsx └── multiselect.test.tsx ├── .eslintrc.js └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | require("@testing-library/jest-dom") -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-listbox", 3 | "regions": ["syd1"] 4 | } 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ["./jest.setup.js"], 3 | } -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | 2 | export const parameters = { 3 | actions: { argTypesRegex: "^on[A-Z].*" }, 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | tailwind.css 7 | **/tailwind.css 8 | storybook-static/ -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | let id = 0 2 | 3 | export const generateId = (): string => `tailwind-ui-listbox-id-${++id}` 4 | 5 | export default null 6 | -------------------------------------------------------------------------------- /stories/utils/cn.ts: -------------------------------------------------------------------------------- 1 | const cn = (...classes: Array ): string => classes.filter(Boolean).join(" ") 2 | export default cn 3 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ["../stories/**/*.stories.mdx", "../stories/**/*.stories.@(js|jsx|ts|tsx)"], 3 | addons: ["@storybook/addon-links", "@storybook/addon-essentials"] 4 | }; 5 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { Listbox } from "./Listbox" 2 | import { ListboxButton } from "./ListboxButton" 3 | import { ListboxLabel } from "./ListboxLabel" 4 | import { ListboxList } from "./ListboxList" 5 | import { ListboxOption } from "./ListboxOption" 6 | 7 | export { 8 | Listbox, 9 | ListboxButton, 10 | ListboxLabel, 11 | ListboxList, 12 | ListboxOption, 13 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const defaultTheme = require("tailwindcss/defaultTheme") 3 | 4 | module.exports = { 5 | future: "all", 6 | purge: { 7 | mode: "all", 8 | content: [ 9 | "./**/*.tsx", 10 | "./**/*.ts", 11 | "./index.html", 12 | ], 13 | }, 14 | theme: { 15 | extend: { fontFamily: { sans: ["Inter var", ...defaultTheme.fontFamily.sans] } }, 16 | container: { center: true }, 17 | }, 18 | variants: {}, 19 | plugins: [require("@tailwindcss/ui")], 20 | } 21 | /* eslint-enable @typescript-eslint/no-var-requires */ 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "exclude": ["dist", "storybook-static"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "baseUrl": "./", 18 | "paths": { 19 | "@": ["./"], 20 | "*": ["src/*", "node_modules/*"] 21 | }, 22 | "jsx": "react", 23 | "esModuleInterop": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/ListboxLabel.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react" 2 | import { ListboxContext } from "./Listbox" 3 | import { generateId } from "./utils" 4 | 5 | type Props = { 6 | children: React.ReactNode | string, 7 | className?: string 8 | } 9 | 10 | type State = { 11 | id: string 12 | } 13 | 14 | export class ListboxLabel extends Component { 15 | constructor(props: Props) { 16 | super(props) 17 | this.state = { id: generateId() } 18 | } 19 | 20 | componentDidMount(): void { 21 | this.context.setLabelId(this.state.id) 22 | } 23 | 24 | render(): React.ReactNode { 25 | const { children, className } = this.props 26 | return ( children && {children} ) 27 | } 28 | } 29 | 30 | ListboxLabel.contextType = ListboxContext 31 | export default null -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 12 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | env: 27 | CI: true 28 | 29 | - name: Lint 30 | run: yarn lint 31 | env: 32 | CI: true 33 | 34 | - name: Test 35 | run: yarn test --ci --coverage --maxWorkers=2 36 | env: 37 | CI: true 38 | 39 | - name: Build 40 | run: yarn build 41 | env: 42 | CI: true 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mitch Smith 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. -------------------------------------------------------------------------------- /src/ListboxButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react" 2 | import { ListboxContext } from "./Listbox" 3 | import { generateId } from "./utils" 4 | 5 | type Props = { 6 | children: (({ isFocused }: { isFocused: boolean }) => React.ReactNode | string ) | string | React.ReactNode | null, 7 | className?: string, 8 | } 9 | 10 | type State = { 11 | isFocused: boolean, 12 | id: string 13 | } 14 | 15 | export class ListboxButton extends Component { 16 | constructor(props: Props){ 17 | super(props) 18 | this.state = { 19 | id: generateId(), 20 | isFocused: false, 21 | } 22 | } 23 | 24 | 25 | componentDidMount(): void{ 26 | this.context.setButtonId(this.state.id) 27 | this.context.setListboxButtonRef(this.ownRef) 28 | } 29 | ownRef: HTMLElement | null = null 30 | 31 | focus = (): void => this.setState({ isFocused: true }) 32 | blur = (): void => this.setState({ isFocused: false }) 33 | 34 | render(): React.ReactNode { 35 | const { children, className } = this.props 36 | const { isFocused } = this.state 37 | return ( 38 | 52 | ) 53 | } 54 | } 55 | 56 | ListboxButton.contextType = ListboxContext 57 | export default null -------------------------------------------------------------------------------- /src/ListboxOption.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react" 2 | import { ListboxContext } from "./Listbox" 3 | import { generateId } from "./utils" 4 | 5 | type Props = { 6 | children: (({ isActive, isSelected }: { isActive: boolean, isSelected: boolean }) => React.ReactNode | string) | React.ReactNode | string | null, 7 | className?: string, 8 | value: string 9 | } 10 | 11 | type State = { 12 | id: string 13 | } 14 | 15 | export class ListboxOption extends Component { 16 | constructor(props: Props){ 17 | super(props) 18 | this.state = { id: generateId() } 19 | } 20 | 21 | componentDidMount(): void{ 22 | this.context.registerOptionRef(this.props.value, this.ownRef) 23 | this.context.registerOptionId(this.props.value, this.state.id) 24 | } 25 | 26 | componentWillUnmount(): void{ 27 | this.context.unregisterOptionId(this.props.value) 28 | this.context.unregisterOptionRef(this.props.value) 29 | } 30 | ownRef: HTMLElement | null = null 31 | 32 | handleClick = (): void => { 33 | this.context.select(this.props.value) 34 | } 35 | 36 | handleMouseMove = (): void => { 37 | if (this.context.activeItem === this.props.value) { return } // this option is already active 38 | 39 | this.context.setActiveItem(this.props.value) 40 | } 41 | 42 | isSelected = (): boolean => { 43 | if (this.context.props.multiselect) { 44 | return this.context.props.values.includes(this.props.value) 45 | } else { 46 | return this.context.props.value === this.props.value 47 | } 48 | } 49 | 50 | render(): React.ReactNode{ 51 | const { children, className } = this.props 52 | const isActive = this.context.activeItem === this.props.value 53 | const isSelected = this.isSelected() 54 | 55 | return ( 56 |
  • this.ownRef = el} 62 | role="option" 63 | tabIndex={-1} 64 | {...isSelected ? { "aria-selected": true } : {}} 65 | > 66 | { children instanceof Function ? children({ isSelected, isActive }) : children } 67 |
  • 68 | ) 69 | } 70 | } 71 | 72 | ListboxOption.contextType = ListboxContext 73 | export default null -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mitchbne/react-listbox", 3 | "author": "Mitch Smith", 4 | "module": "dist/react-listbox.esm.js", 5 | "version": "0.1.4", 6 | "license": "MIT", 7 | "main": "dist/index.js", 8 | "typings": "dist/index.d.ts", 9 | "files": [ 10 | "dist" 11 | ], 12 | "engines": { 13 | "node": ">=10" 14 | }, 15 | "scripts": { 16 | "start": "tsdx watch", 17 | "build": "tsdx build", 18 | "storybook": "NODE_ENV=development yarn run tailwindcss && start-storybook -p 6006", 19 | "build-storybook": "NODE_ENV=production yarn run tailwindcss:prod && build-storybook", 20 | "test": "tsdx test --passWithNoTests", 21 | "lint": "tsdx lint src test example", 22 | "prepare": "tsdx build", 23 | "tailwindcss": "tailwindcss build -o ./stories/tailwind.css", 24 | "tailwindcss:prod": "tailwindcss build -o ./stories/tailwind.css" 25 | }, 26 | "peerDependencies": { 27 | "react": ">=16" 28 | }, 29 | "husky": { 30 | "hooks": { 31 | "pre-commit": "tsdx lint src test example" 32 | } 33 | }, 34 | "prettier": {}, 35 | "devDependencies": { 36 | "@storybook/addon-actions": "^6.0.16", 37 | "@storybook/addon-essentials": "^6.0.16", 38 | "@storybook/addon-links": "^6.0.16", 39 | "@storybook/react": "^6.0.16", 40 | "@tailwindcss/ui": "^0.5.0", 41 | "@testing-library/jest-dom": "^5.11.4", 42 | "@testing-library/react": "^10.4.9", 43 | "@testing-library/user-event": "^12.1.3", 44 | "@types/react": "^16.9.46", 45 | "@types/react-dom": "^16.9.8", 46 | "@typescript-eslint/eslint-plugin": "^3.9.0", 47 | "@typescript-eslint/parser": "^3.9.0", 48 | "eslint": "^7.7.0", 49 | "eslint-config-react-app": "^5.2.1", 50 | "eslint-config-standard": "^14.1.1", 51 | "eslint-import-resolver-webpack": "^0.12.2", 52 | "eslint-plugin-compat": "^3.8.0", 53 | "eslint-plugin-eslint-comments": "^3.2.0", 54 | "eslint-plugin-import": "^2.22.0", 55 | "eslint-plugin-jest": "^23.20.0", 56 | "eslint-plugin-jsx-a11y": "^6.3.1", 57 | "eslint-plugin-only-var": "^0.1.2", 58 | "eslint-plugin-promise": "^4.2.1", 59 | "eslint-plugin-react": "^7.20.6", 60 | "eslint-plugin-react-hooks": "^4.0.8", 61 | "husky": "^4.2.5", 62 | "react": "^16.13.1", 63 | "react-dom": "^16.13.1", 64 | "tailwindcss": "^1.7.6", 65 | "tsdx": "^0.13.2", 66 | "tslib": "^2.0.1", 67 | "typescript": "^3.9.7" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import userEvent from "@testing-library/user-event" 3 | import { render, screen } from "@testing-library/react" 4 | import { 5 | Listbox, 6 | ListboxButton, 7 | ListboxLabel, 8 | ListboxList, 9 | ListboxOption, 10 | } from "../src" 11 | 12 | describe("given listbox is determined by isOpen", () => { 13 | beforeEach(() => { 14 | jest.useFakeTimers() 15 | Element.prototype.scrollIntoView = jest.fn() 16 | }) 17 | 18 | const onChange = jest.fn() 19 | const optionValues = ["item1", "item2", "item3"] 20 | const value = "item2" 21 | 22 | describe("by default", () => { 23 | beforeEach(() => setup({ onChange, optionValues, value })) 24 | 25 | it("the listbox should be closed", () => { 26 | const listbox = screen.queryByRole("listbox") 27 | expect(listbox).not.toBeInTheDocument() 28 | }) 29 | 30 | it("the button is labeled by the label component", () => { 31 | const label = screen.getByLabelText("Select something") 32 | const button = screen.getByRole("button") 33 | expect(label).toContainElement(button) 34 | }) 35 | 36 | it("the button is not focused", () => { 37 | const button = screen.getByRole("button") 38 | expect(button).toHaveTextContent("Click me") 39 | }) 40 | }) 41 | 42 | describe("when clicking on the button", () => { 43 | beforeEach(() => { 44 | setup({ onChange, optionValues, value }) 45 | userEvent.click(screen.getByText("Click me")) 46 | }) 47 | 48 | it("then displays the listbox", () => { 49 | expect(screen.getByRole("listbox")).toBeInTheDocument() 50 | }) 51 | 52 | describe("when clicking on an option", () => { 53 | beforeEach(() => { 54 | const [option] = screen.getAllByRole("option") 55 | userEvent.click(option) 56 | // Flush out async focus code 57 | jest.runAllTimers() 58 | }) 59 | 60 | it("then calls onChange", () => { 61 | expect(onChange).toHaveBeenCalledWith(optionValues[0]) 62 | }) 63 | 64 | it("then closes the list", () => { 65 | expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); 66 | }) 67 | }) 68 | }) 69 | 70 | describe("when focus shifts to the button", () => { 71 | beforeEach(() => { 72 | setup({ onChange, optionValues, value }) 73 | userEvent.tab() 74 | }) 75 | 76 | it("the button appears focused", () => { 77 | const button = screen.getByRole("button") 78 | expect(button).toHaveTextContent("Click me (focused)") 79 | }) 80 | }) 81 | 82 | 83 | describe("given an option is selected", () => { 84 | beforeEach(() => { 85 | setup({ onChange, optionValues, value }) 86 | userEvent.click(screen.getByText("Click me")) 87 | }) 88 | 89 | it("has a selected state", () => { 90 | const option = screen.getByText("Item 2 selected") 91 | expect(option).toBeInTheDocument() 92 | }) 93 | }) 94 | 95 | describe("given an option is NOT selected", () => { 96 | beforeEach(() => { 97 | setup({ onChange, optionValues, value: "item1" }) 98 | userEvent.click(screen.getByText("Click me")) 99 | }) 100 | 101 | it("has a selected state", () => { 102 | const option = screen.getByText("Item 2 not selected") 103 | expect(option).toBeInTheDocument() 104 | }) 105 | }) 106 | 107 | describe("given an option is active", () => { 108 | beforeEach(() => { 109 | setup({ onChange, optionValues, value }) 110 | userEvent.click(screen.getByText("Click me")) 111 | // NOT active before hover 112 | userEvent.hover(screen.getByText("Item 3 not active")) 113 | }) 114 | 115 | it("has a selected state", () => { 116 | const option = screen.getByText("Item 3 active") 117 | expect(option).toBeInTheDocument() 118 | }) 119 | }) 120 | }) 121 | 122 | interface Setup { 123 | onChange: () => void; 124 | optionValues: string[]; 125 | value: string; 126 | } 127 | 128 | function setup({ onChange, optionValues, value }: Setup) { 129 | render( 130 | 131 | {({ isOpen }: { isOpen: boolean }) => ( 132 | <> 133 | Select something 134 | {({ isFocused }) => <>{isFocused ? "Click me (focused)" : "Click me"}} 135 | {isOpen && 136 | Item 1 137 | {({ isSelected }) => <>{isSelected ? "Item 2 selected" : "Item 2 not selected"}} 138 | {({ isActive }) => <>{isActive ? "Item 3 active" : "Item 3 not active"}} 139 | } 140 | 141 | )} 142 | 143 | ) 144 | } 145 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true, 6 | }, 7 | settings: { "import/resolver": { node: { extensions: [".js", ".jsx", ".ts", ".tsx", ".jpeg", ".png"] } } }, 8 | extends: [ 9 | "eslint:recommended", 10 | "plugin:react/recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:jest/recommended", 13 | ], 14 | globals: { 15 | Atomics: "readonly", 16 | SharedArrayBuffer: "readonly", 17 | }, 18 | parser: "@typescript-eslint/parser", 19 | parserOptions: { 20 | ecmaFeatures: { jsx: true }, 21 | ecmaVersion: 2018, 22 | sourceType: "module", 23 | }, 24 | plugins: [ 25 | "react", 26 | "react-hooks", 27 | "compat", 28 | "import", 29 | "eslint-comments", 30 | "@typescript-eslint", 31 | "jest", 32 | ], 33 | rules: { 34 | "react-hooks/rules-of-hooks": "error", 35 | "react-hooks/exhaustive-deps": "error", 36 | indent: ["error", 2], 37 | "prefer-const": 2, 38 | "arrow-body-style": 2, 39 | "arrow-parens": [2, "as-needed", { requireForBlockBody: true }], 40 | "no-var": 2, 41 | "no-undef": 2, 42 | "compat/compat": "error", 43 | "comma-dangle": ["error", { 44 | arrays: "always-multiline", 45 | objects: "always-multiline", 46 | imports: "always-multiline", 47 | exports: "always-multiline", 48 | functions: "never", 49 | }], 50 | curly: ["error", "all"], 51 | quotes: ["error", "double", { 52 | avoidEscape: true, 53 | allowTemplateLiterals: true, 54 | }], 55 | camelcase: "off", 56 | "no-extend-native": ["error", { exceptions: ["Array", "String", "Date", "Number"] }], 57 | "no-multi-spaces": [2, { 58 | exceptions: { 59 | Property: true, 60 | VariableDeclarator: true, 61 | BinaryExpression: true, 62 | AssignmentExpression: true, 63 | }, 64 | ignoreEOLComments: true, 65 | }], 66 | "padded-blocks": [2, { 67 | blocks: "never", 68 | switches: "never", 69 | }], 70 | semi: ["error", "never"], 71 | "no-bitwise": 2, 72 | "no-implicit-coercion": ["error", { 73 | boolean: false, 74 | number: false, 75 | string: true, 76 | }], 77 | "import/no-unresolved": 2, 78 | "import/named": 2, 79 | "import/default": 2, 80 | "import/namespace": 2, 81 | "import/no-mutable-exports": 2, 82 | "import/no-extraneous-dependencies": 2, 83 | "import/export": 2, 84 | "import/no-nodejs-modules": 1, 85 | "import/first": 2, 86 | "import/order": [2, { groups: ["builtin", "external", "internal", "parent", "sibling", "index"] }], 87 | "import/no-duplicates": 2, 88 | "import/extensions": 2, 89 | "import/newline-after-import": 2, 90 | "import/prefer-default-export": 2, 91 | "react/prop-types": 0, 92 | "react/jsx-wrap-multilines": 2, 93 | "react/display-name": 1, 94 | "react/jsx-tag-spacing": [2, { 95 | beforeSelfClosing: "always", 96 | closingSlash: "never", 97 | afterOpening: "never", 98 | }], 99 | "react/jsx-closing-bracket-location": 2, 100 | "react/jsx-boolean-value": 2, 101 | "react/react-in-jsx-scope": 0, 102 | "react/jsx-filename-extension": [2, { extensions: [".js", ".jsx", ".ts", ".tsx"] }], 103 | "react/jsx-pascal-case": 2, 104 | "react/jsx-max-props-per-line": [2, { when: "multiline" }], 105 | "react/jsx-indent-props": [2, 2], 106 | "react/jsx-indent": [2, 2], 107 | "react/void-dom-elements-no-children": 2, 108 | "react/sort-comp": [2, { 109 | order: [ 110 | "type-annotations", 111 | "static-methods", 112 | "lifecycle", 113 | "everything-else", 114 | "render", 115 | ], 116 | }], 117 | "react/self-closing-comp": 2, 118 | "react/no-array-index-key": 2, 119 | "react/no-redundant-should-component-update": 2, 120 | "react/no-multi-comp": [2, { ignoreStateless: true }], 121 | "react/prefer-es6-class": 2, 122 | "react/prefer-stateless-function": [2, { ignorePureComponents: true }], 123 | "react/no-direct-mutation-state": 2, 124 | "react/require-default-props": 2, 125 | "react/no-unused-prop-types": 2, 126 | "react/jsx-equals-spacing": [2, "never"], 127 | "react/jsx-curly-spacing": [2, { when: "never" }], 128 | "react/jsx-sort-props": ["error", { ignoreCase: true }], 129 | "no-console": 1, 130 | "array-bracket-spacing": [2, "never"], 131 | "object-curly-spacing": [2, "always"], 132 | "object-curly-newline": [2, { 133 | ObjectExpression: { multiline: true }, 134 | ObjectPattern: { multiline: true }, 135 | ImportDeclaration: { consistent: true }, 136 | ExportDeclaration: { consistent: true }, 137 | }], 138 | "quote-props": [2, "as-needed", { numbers: true }], 139 | "no-useless-computed-key": 2, 140 | "no-unexpected-multiline": "error", 141 | "react/no-typos": 2, 142 | "react/jsx-no-target-blank": 2, 143 | "no-unused-expressions": 0, 144 | "eslint-comments/disable-enable-pair": 2, 145 | "eslint-comments/no-duplicate-disable": 2, 146 | "eslint-comments/no-unlimited-disable": 2, 147 | "eslint-comments/no-unused-disable": 2, 148 | "eslint-comments/no-unused-enable": 2, 149 | "jest/no-disabled-tests": "warn", 150 | "jest/no-focused-tests": "error", 151 | "jest/no-identical-title": "error", 152 | "jest/prefer-to-have-length": "warn", 153 | "jest/valid-expect": "error", 154 | "jest/expect-expect": 0, 155 | }, 156 | } -------------------------------------------------------------------------------- /src/ListboxList.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react" 2 | import { ListboxContext } from "./Listbox" 3 | 4 | type Props = { 5 | children: React.ReactElement[], 6 | className?: string 7 | } 8 | 9 | type State = { 10 | focusedIndex: number | null, 11 | values: string[] | null 12 | } 13 | 14 | function isString(value: any): value is string { // eslint-disable-line @typescript-eslint/no-explicit-any 15 | return typeof value === "string" || value instanceof String 16 | } 17 | 18 | export class ListboxList extends Component { 19 | constructor(props: Props){ 20 | super(props) 21 | this.state = { focusedIndex: null, values: null } 22 | } 23 | 24 | componentDidMount(): void{ 25 | this.context.setListboxListRef(this.ownRef) 26 | const values: string[] = this.props.children.map(node => node.props.value) 27 | this.setState({ values }, (): void => { this.context.setValues(values) }) 28 | } 29 | ownRef: HTMLElement | null = null 30 | 31 | handleBlur = (e: React.FocusEvent): void => { 32 | if (e.relatedTarget === this.context.listboxButtonRef){ return } // The button will already handle the toggle for us 33 | if (!this.context.props.multiselect) { 34 | this.context.close() 35 | } 36 | } 37 | 38 | handleKeydown = (e: React.KeyboardEvent): void => { 39 | const focusedIndex = this.state.values?.indexOf(this.context.activeItem) 40 | // focusedIndex is -1 if this.context.activeItem is not in this.state.values 41 | if (focusedIndex === -1 || !this.state.values){ return } 42 | let indexToFocus 43 | switch (e.key) { 44 | case "Esc": 45 | case "Escape": 46 | e.preventDefault() 47 | this.context.close() 48 | break 49 | case "Tab": 50 | e.preventDefault() 51 | this.context.close() 52 | break 53 | case "End": 54 | e.preventDefault() 55 | if (e.shiftKey && e.ctrlKey && this.context.props.multiselect) { 56 | const activeIndex = this.context.values.indexOf(this.context.activeItem) 57 | this.context.selectMany(this.context.values.slice(activeIndex, this.context.values.length)) 58 | } 59 | this.context.focus(this.state.values[this.state.values.length - 1]) 60 | break 61 | case "Home": 62 | e.preventDefault() 63 | if (e.shiftKey && e.ctrlKey && this.context.props.multiselect) { 64 | const activeIndex = this.context.values.indexOf(this.context.activeItem) 65 | this.context.selectMany(this.context.values.slice(0, activeIndex)) 66 | } 67 | this.context.focus(this.state.values[0]) 68 | break 69 | case "Up": 70 | case "ArrowUp": 71 | e.preventDefault() 72 | if (focusedIndex || focusedIndex === 0){ // Typescript makes us check this. 73 | indexToFocus = focusedIndex - 1 < 0 ? this.state.values?.length - 1 : focusedIndex - 1 74 | this.context.focus(this.state.values[indexToFocus]) 75 | 76 | if (this.context.props.multiselect && e.shiftKey) { 77 | this.context.select(this.state.values[indexToFocus]) 78 | } 79 | } 80 | break 81 | case "Down": 82 | case "ArrowDown": 83 | e.preventDefault() 84 | if (focusedIndex || focusedIndex === 0){ // Typescript makes us check this. 85 | indexToFocus = focusedIndex + 1 > this.state.values?.length - 1 ? 0 : focusedIndex + 1 86 | this.context.focus(this.state.values[indexToFocus]) 87 | 88 | if (this.context.props.multiselect && e.shiftKey) { 89 | this.context.select(this.state.values[indexToFocus]) 90 | } 91 | } 92 | break 93 | case "Spacebar": 94 | case " ": 95 | e.preventDefault() 96 | if (this.context.typeahead !== "") { 97 | this.context.type(" ") 98 | } else if (e.shiftKey && this.context.props.multiselect) { 99 | const lastIndex = this.context.values.indexOf(this.context.lastSelected) 100 | const activeIndex = this.context.values.indexOf(this.context.activeItem) 101 | 102 | const toSelect = lastIndex > activeIndex ? this.context.values.slice(activeIndex, lastIndex) : this.context.values.slice(lastIndex + 1, activeIndex + 1) 103 | this.context.selectMany(toSelect) 104 | } else { 105 | this.context.select(this.context.activeItem || this.context.props) 106 | } 107 | break 108 | case "Enter": 109 | e.preventDefault() 110 | this.context.select(this.context.activeItem || this.context.props) 111 | break 112 | case "a": 113 | if (!this.context.props.multiselect) { return } 114 | e.preventDefault() 115 | if (this.context.values.every((value: string) => this.context.props.values.includes(value))) { 116 | this.context.props.onChange([]) 117 | } else { 118 | this.context.selectMany(this.context.values) 119 | } 120 | break 121 | default: 122 | if (!(isString(e.key) && e.key.length === 1)) { 123 | return 124 | } 125 | 126 | e.preventDefault() 127 | this.context.type(e.key) 128 | return 129 | } 130 | } 131 | 132 | handleMouseLeave = (): void => { 133 | this.context.setActiveItem(null) 134 | } 135 | 136 | render(): React.ReactNode{ 137 | const { children, className } = this.props 138 | return ( 139 |
      this.ownRef = el} 147 | role="listbox" 148 | tabIndex={-1} 149 | > 150 | {children} 151 |
    152 | ) 153 | } 154 | } 155 | 156 | ListboxList.contextType = ListboxContext 157 | 158 | export default null 159 | -------------------------------------------------------------------------------- /src/Listbox.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react" 2 | 3 | export const ListboxContext = React.createContext({}) 4 | 5 | interface SharedProps { 6 | children: React.ReactNode; 7 | className?: string; 8 | } 9 | 10 | interface SingleselectProps extends SharedProps { 11 | multiselect?: false; 12 | value: string | null; 13 | onChange: (value: string | null) => void; 14 | } 15 | 16 | interface MultiselectProps extends SharedProps { 17 | multiselect: true; 18 | values: string[]; 19 | onChange: (value: string[]) => void; 20 | } 21 | 22 | type Props = SingleselectProps | MultiselectProps 23 | 24 | type State = { 25 | typeahead: string, 26 | listboxButtonRef: HTMLElement | null, 27 | listboxListRef: HTMLElement | null, 28 | isOpen: boolean, 29 | activeItem: string | null, 30 | values: Array, 31 | labelId: string | null, 32 | buttonId: string | null, 33 | optionIds: Array<[string, string]>, 34 | optionRefs: Array<[string, HTMLElement]>, 35 | lastSelected: string | null; 36 | } 37 | 38 | export class Listbox extends Component { 39 | static defaultProps = { values: [] } 40 | 41 | constructor(props: Props){ 42 | super(props) 43 | this.state = { 44 | typeahead: "", 45 | listboxButtonRef: null, 46 | listboxListRef: null, 47 | isOpen: false, 48 | activeItem: null, 49 | values: [], 50 | labelId: null, 51 | buttonId: null, 52 | optionIds: [], 53 | optionRefs: [], 54 | lastSelected: null, 55 | } 56 | } 57 | 58 | getActiveDescendant = (): string | null => { 59 | const [, id] = this.state.optionIds.find(([value]) => value === this.state.activeItem) || [null, null] 60 | return id 61 | } 62 | 63 | registerOptionId = (value: string, optionId: string): void => { 64 | this.unregisterOptionId(value) 65 | this.setState(prevState => ({ optionIds: [...prevState.optionIds, [value, optionId]] } )) 66 | } 67 | 68 | unregisterOptionId = (value: string): void => { 69 | this.setState(prevState => ({ optionIds: prevState.optionIds.filter(([candidateValue]) => candidateValue !== value) }) ) 70 | } 71 | 72 | type = (value: string): void => { 73 | this.setState(prevState => ({ typeahead: prevState.typeahead.concat(value) }), () => { 74 | const [match] = this.state.optionRefs.find( 75 | ([, ref]) => { 76 | const el: HTMLElement = ref 77 | return el.innerText.toLowerCase().startsWith(this.state.typeahead.toLowerCase()) 78 | } 79 | ) || [null] 80 | 81 | if (match !== null) { this.focus(match) } 82 | 83 | this.clearTypeahead() 84 | }) 85 | } 86 | 87 | clearTypeahead = (): void => { 88 | setTimeout(() => {this.setState({ typeahead: "" }) }, 500) 89 | } 90 | 91 | registerOptionRef = (value: string, optionRef: HTMLElement): void => { 92 | this.unregisterOptionRef(value) 93 | this.setState(prevState => ({ optionRefs: [...prevState.optionRefs, [value, optionRef]] })) 94 | } 95 | 96 | unregisterOptionRef = (value: string): void => { 97 | this.setState(prevState => ({ optionRefs: prevState.optionRefs.filter(([candidateValue]) => candidateValue !== value) })) 98 | } 99 | 100 | toggle = (): void => { this.state.isOpen ? this.close() : this.open() } 101 | 102 | open = (): void => { 103 | this.setState({ isOpen: true }, () => { 104 | window.setTimeout(() => { 105 | if (this.state.listboxListRef){ 106 | this.focus(this.getDefaultFocusValue()) 107 | window.setTimeout(() => { 108 | this.state.listboxListRef?.focus() 109 | }, 0) 110 | } 111 | }, 0) 112 | }) 113 | } 114 | 115 | getDefaultFocusValue = (): string | null => { 116 | // Set active value to be the first option 117 | // in the list if no item is selected. 118 | // https://www.w3.org/TR/wai-aria-practices/#listbox_kbd_interaction 119 | const firstSelectedOption = this.props.multiselect ? this.props.values[0] : this.props.value 120 | return firstSelectedOption || this.state.values[0] 121 | } 122 | 123 | close = (): void => { 124 | this.setState({ isOpen: false }, () => { this.state.listboxButtonRef?.focus() }) 125 | } 126 | 127 | select = (value: string): void => { 128 | if (this.props.multiselect) { 129 | this.props.onChange(this.sortByValues(this.toggleValue(value))) 130 | } else { 131 | this.props.onChange(value) 132 | process.nextTick(() => { 133 | this.close() 134 | }) 135 | } 136 | 137 | this.setState({ lastSelected: value }) 138 | } 139 | 140 | selectMany = (values: string[]): void => { 141 | if (this.props.multiselect) { 142 | const newValues = [...values, ...this.props.values] 143 | const dedupedNewValues = newValues.filter((value, index) => index === newValues.indexOf(value) ) 144 | this.props.onChange(this.sortByValues(dedupedNewValues)) 145 | } 146 | } 147 | 148 | sortByValues(values: string[]): string[] { 149 | const indexOf = (value: string) => this.state.values.indexOf(value) 150 | return values.sort((a, b) => { 151 | if (indexOf(a) > indexOf(b)) { 152 | return 1 153 | } else if (indexOf(a) < indexOf(b)) { 154 | return -1 155 | } 156 | 157 | return 0 158 | }) 159 | } 160 | 161 | toggleValue(value: string): string[] { 162 | if (this.props.multiselect) { 163 | const values = this.props.values 164 | return values.includes(value) ? values.filter(v => v !== value) : [value, ...values] 165 | } 166 | 167 | return [] 168 | } 169 | 170 | focus = (value: string | null): void => { 171 | this.setState({ activeItem: value }, () => { 172 | if (value === null){ return } 173 | this.state.listboxListRef?.children[this.state.values.indexOf(value)].scrollIntoView({ block: "nearest" }) 174 | }) 175 | } 176 | 177 | setListboxButtonRef = (ref: HTMLElement): void => { this.setState({ listboxButtonRef: ref })} 178 | setListboxListRef = (ref: HTMLElement): void => { this.setState({ listboxListRef: ref })} 179 | setButtonId = (id: string): void => { this.setState({ buttonId: id })} 180 | setLabelId = (id: string): void => { this.setState({ labelId: id })} 181 | setValues = (values: string[]): void => { this.setState({ values })} 182 | setActiveItem = (activeItem: string): void => { this.setState({ activeItem })} 183 | 184 | render(): React.ReactNode { 185 | const { children, className } = this.props 186 | const { isOpen } = this.state 187 | 188 | const ProvidedContext = { 189 | getActiveDescendant: this.getActiveDescendant, 190 | registerOptionId: this.registerOptionId, 191 | unregisterOptionId: this.unregisterOptionId, 192 | registerOptionRef: this.registerOptionRef, 193 | unregisterOptionRef: this.unregisterOptionRef, 194 | toggle: this.toggle, 195 | open: this.open, 196 | close: this.close, 197 | select: this.select, 198 | selectMany: this.selectMany, 199 | focus: this.focus, 200 | clearTypeahead: this.clearTypeahead, 201 | typeahead: this.state.typeahead, 202 | type: this.type, 203 | setListboxListRef: this.setListboxListRef, 204 | setListboxButtonRef: this.setListboxButtonRef, 205 | listboxButtonRef: this.state.listboxButtonRef, 206 | listboxListRef: this.state.listboxListRef, 207 | lastSelected: this.state.lastSelected, 208 | isOpen: this.state.isOpen, 209 | activeItem: this.state.activeItem, 210 | setActiveItem: this.setActiveItem, 211 | values: this.state.values, 212 | setValues: this.setValues, 213 | labelId: this.state.labelId, 214 | setLabelId: this.setLabelId, 215 | buttonId: this.state.buttonId, 216 | setButtonId: this.setButtonId, 217 | props: this.props, 218 | } 219 | 220 | return ( 221 | 222 |
    223 | { children instanceof Function ? children({ isOpen }) : children } 224 |
    225 |
    226 | ) 227 | } 228 | } 229 | 230 | export default null 231 | -------------------------------------------------------------------------------- /test/multiselect.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import userEvent from "@testing-library/user-event" 3 | import { fireEvent, render, screen } from "@testing-library/react" 4 | import { 5 | Listbox, 6 | ListboxButton, 7 | ListboxLabel, 8 | ListboxList, 9 | ListboxOption, 10 | } from "../src" 11 | 12 | describe("given listbox is determined by isOpen", () => { 13 | beforeEach(() => { 14 | jest.useFakeTimers() 15 | Element.prototype.scrollIntoView = jest.fn() 16 | }) 17 | 18 | afterEach(() => { 19 | jest.clearAllMocks() 20 | }) 21 | 22 | const onChange = jest.fn() 23 | const optionValues = ["item0", "item1", "item2", "item3", "item4"] 24 | const value = ["item2"] 25 | 26 | describe("by default", () => { 27 | beforeEach(() => setup({ onChange, optionValues, value, openListbox: false })) 28 | 29 | it("the listbox should be closed", () => { 30 | const listbox = screen.queryByRole("listbox") 31 | expect(listbox).not.toBeInTheDocument() 32 | }) 33 | 34 | it("the button is labeled by the label component", () => { 35 | const label = screen.getByLabelText("Select something") 36 | const button = screen.getByRole("button") 37 | expect(label).toContainElement(button) 38 | }) 39 | 40 | it("the button is not focused", () => { 41 | const button = screen.getByRole("button") 42 | expect(button).toHaveTextContent("Click me") 43 | }) 44 | }) 45 | 46 | describe("when clicking on the button", () => { 47 | it("then displays the listbox", () => { 48 | setup({ onChange, optionValues, value, openListbox: true }) 49 | expect(screen.getByRole("listbox")).toBeInTheDocument() 50 | }) 51 | 52 | it("then focuses the first selected option", () => { 53 | setup({ onChange, optionValues, value, openListbox: true }) 54 | expect(screen.getByText("Item 2 (selected) (active)")).toBeInTheDocument() 55 | expect(Element.prototype.scrollIntoView).toHaveBeenCalled() 56 | }) 57 | 58 | describe("when clicking on an option", () => { 59 | beforeEach(() => { 60 | setup({ onChange, optionValues, value, openListbox: true }) 61 | const item0 = screen.getByText("Item 0") 62 | userEvent.click(item0) 63 | }) 64 | 65 | it("then calls onChange with values ordered based on options", () => { 66 | expect(onChange).toHaveBeenCalledWith(["item0", "item2"]) 67 | }) 68 | 69 | it("then does NOT close the list", () => { 70 | expect(screen.queryByRole("listbox")).toBeInTheDocument() 71 | }) 72 | }) 73 | 74 | describe("when clicking on an already selected option", () => { 75 | beforeEach(() => { 76 | setup({ onChange, optionValues, value, openListbox: true }) 77 | const item0 = screen.getByText("Item 2 (selected) (active)") 78 | userEvent.click(item0) 79 | }) 80 | 81 | it("then calls onChange with that value removed", () => { 82 | expect(onChange).toHaveBeenCalledWith([]) 83 | }) 84 | 85 | it("then does NOT close the list", () => { 86 | expect(screen.queryByRole("listbox")).toBeInTheDocument() 87 | }) 88 | }) 89 | 90 | describe("when pressing shift", () => { 91 | describe.each(["Down", "ArrowDown"])("and %i", (key) => { 92 | it("then selects the next value", () => { 93 | setup({ onChange, optionValues, value, openListbox: true }) 94 | fireEvent.keyDown(screen.getByRole("listbox"), { key, shiftKey: true }) 95 | expect(onChange).toHaveBeenCalledWith(["item2", "item3"]) 96 | }) 97 | }) 98 | 99 | describe.each(["Up", "ArrowUp"])("and %i", (key) => { 100 | it("then selects the next value", () => { 101 | setup({ onChange, optionValues, value, openListbox: true }) 102 | fireEvent.keyDown(screen.getByRole("listbox"), { key, shiftKey: true }) 103 | expect(onChange).toHaveBeenCalledWith(["item1", "item2"]) 104 | }) 105 | }) 106 | 107 | describe.each(["Spacebar"])("and %i", (key) => { 108 | describe("When moving up", () => { 109 | const keyArrow = "ArrowUp" 110 | 111 | it("then selects contiguous items from the most recently selected item to the focused item", () => { 112 | setup({ onChange, optionValues, value, openListbox: true }) 113 | userEvent.click(screen.getByText("Item 4")) 114 | fireEvent.keyDown(screen.getByRole("listbox"), { key: keyArrow }) 115 | fireEvent.keyDown(screen.getByRole("listbox"), { key: keyArrow }) 116 | fireEvent.keyDown(screen.getByRole("listbox"), { key: keyArrow }) 117 | fireEvent.keyDown(screen.getByRole("listbox"), { key, shiftKey: true }) 118 | expect(onChange).toHaveBeenCalledWith(["item1", "item2", "item3"]) 119 | }) 120 | }) 121 | 122 | describe("When moving down", () => { 123 | const keyArrow = "ArrowDown" 124 | it("then selects contiguous items from the most recently selected item to the focused item", () => { 125 | setup({ onChange, optionValues, value, openListbox: true }) 126 | userEvent.click(screen.getByText("Item 2 (selected) (active)")) 127 | fireEvent.keyDown(screen.getByRole("listbox"), { key: keyArrow }) 128 | fireEvent.keyDown(screen.getByRole("listbox"), { key: keyArrow }) 129 | fireEvent.keyDown(screen.getByRole("listbox"), { key, shiftKey: true }) 130 | expect(onChange).toHaveBeenCalledWith(["item2", "item3", "item4"]) 131 | }) 132 | }) 133 | }) 134 | 135 | describe("and Ctrl", () => { 136 | describe("and Home", () => { 137 | it("then selects all items from focused item to the first", () => { 138 | setup({ onChange, optionValues, value, openListbox: true }) 139 | fireEvent.keyDown(screen.getByRole("listbox"), { key: "Home", shiftKey: true, ctrlKey: true }) 140 | expect(onChange).toHaveBeenCalledWith(["item0", "item1", "item2"]) 141 | }) 142 | }) 143 | describe("and End", () => { 144 | it("then selects all items from focused item to the first", () => { 145 | setup({ onChange, optionValues, value, openListbox: true }) 146 | fireEvent.keyDown(screen.getByRole("listbox"), { key: "End", shiftKey: true, ctrlKey: true }) 147 | expect(onChange).toHaveBeenCalledWith(["item2", "item3", "item4"]) 148 | }) 149 | }) 150 | }) 151 | }) 152 | 153 | describe("when pressing ctrl", () => { 154 | describe("and A", () => { 155 | it("then selects all items", () => { 156 | setup({ onChange, optionValues, value, openListbox: true }) 157 | fireEvent.keyDown(screen.getByRole("listbox"), { key: "a", ctrlKey: true }) 158 | expect(onChange).toHaveBeenCalledWith(["item0", "item1", "item2", "item3", "item4"]) 159 | }) 160 | 161 | describe("given all items are selected", () => { 162 | it("then unselects all items", () => { 163 | setup({ onChange, optionValues, value: ["item0", "item1", "item2", "item3", "item4"], openListbox: true }) 164 | fireEvent.keyDown(screen.getByRole("listbox"), { key: "a", ctrlKey: true }) 165 | expect(onChange).toHaveBeenCalledWith([]) 166 | }) 167 | }) 168 | }) 169 | }) 170 | }) 171 | }) 172 | 173 | interface Setup { 174 | onChange: () => void; 175 | optionValues: string[]; 176 | value: string[]; 177 | openListbox: boolean; 178 | } 179 | 180 | function setup({ onChange, optionValues, value, openListbox }: Setup) { 181 | const innerOption = (item: string) => ({ isSelected, isActive }: { isSelected: string; isActive: string }) => ( 182 | `${item} ${isSelected ? "(selected)" : ""} ${isActive ? "(active)" : ""}` 183 | ) 184 | 185 | render( 186 | 187 | {({ isOpen }: { isOpen: boolean }) => ( 188 | <> 189 | Select something 190 | {({ isFocused }) => <>{isFocused ? "Click me (focused)" : "Click me"}} 191 | {isOpen && 192 | {innerOption("Item 0")} 193 | {innerOption("Item 1")} 194 | {innerOption("Item 2")} 195 | {innerOption("Item 3")} 196 | {innerOption("Item 4")} 197 | } 198 | 199 | )} 200 | 201 | ) 202 | 203 | if (openListbox) { 204 | userEvent.click(screen.getByText("Click me")) 205 | // Flush out async focus code 206 | jest.runAllTimers() 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /stories/Listbox.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Meta } from "@storybook/react" 3 | import { 4 | Listbox, 5 | ListboxLabel, 6 | ListboxButton, 7 | ListboxList, 8 | ListboxOption, 9 | } from "../src" 10 | import cn from "./utils/cn" 11 | import "./tailwind.css" 12 | 13 | export default { 14 | title: "Example/Listbox", 15 | component: Listbox, 16 | decorators: [ 17 | Story => ( 18 |
    19 |
    20 | 21 |
    22 |
    23 | ), 24 | ], 25 | } as Meta 26 | 27 | const wrestlers = [ 28 | "Stone Cold Steven Austin", 29 | "Bret Hart", 30 | "Ric Flair", 31 | "Macho Man Randy Savage", 32 | "Jake The Snake Roberts", 33 | "The Undertaker", 34 | "Hulk Hogan", 35 | "Rikishi", 36 | "John Cena", 37 | "Shawn Micahels", 38 | "British Bulldog", 39 | "Superfly Jimmy Snuka", 40 | "The Ultimate Warrior", 41 | "Andre The Giant", 42 | "Doink The Clown", 43 | ] 44 | 45 | export const SingleSelect = (): React.ReactElement => { 46 | const [selectedWrestler, setSelectedWrestler] = React.useState(null) 47 | 48 | return ( 49 | 50 | {({ isOpen }) => ( 51 | 52 | 53 | Select a wrestler: 54 | 55 | 56 | {({ isFocused }) => ( 57 | 58 | { selectedWrestler ? selectedWrestler : "Select a wrestler" } 59 | 60 | 61 | 62 | 63 | )} 64 | 65 | {isOpen && ( 66 | 67 | { wrestlers.map(wrestler => ( 68 | 69 | {({ isActive, isSelected }) => ( 70 |
    71 | { wrestler } 72 | { isSelected && ( 73 | 74 | 75 | 76 | 77 | 78 | )} 79 |
    80 | )} 81 |
    82 | ))} 83 |
    84 | )} 85 |
    86 | )} 87 |
    88 | ) 89 | } 90 | 91 | export const SingleSelectAvatar = (): React.ReactElement => { 92 | const [selectedPersonId, setSelectedPersonId] = React.useState(null) 93 | const people = [ 94 | { 95 | "id": "5bbb4afc-d23d-4f33-b84a-251f0aafe8d4", 96 | "name": "Mr. Louisa Durgan", 97 | "avatar": "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 98 | }, 99 | { 100 | "id": "807e05d8-0896-42e0-9f9f-12c493be0da5", 101 | "name": "Maudie Collier II", 102 | "avatar": "https://images.unsplash.com/photo-1491528323818-fdd1faba62cc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 103 | }, 104 | { 105 | "id": "2f8807fd-f9ec-4b52-ad01-51f9d714e3d2", 106 | "name": "Torrance Kuvalis", 107 | "avatar": "https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 108 | }, 109 | { 110 | "id": "7b90f1de-cd62-4cef-84ea-9900dc42ff94", 111 | "name": "Ansley Ferry", 112 | "avatar": "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2.25&w=256&h=256&q=80" 113 | }, 114 | { 115 | "id": "387416e6-dcd0-4acc-a33b-1a3045bbd00c", 116 | "name": "Tyree Ortiz", 117 | "avatar": "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 118 | }, 119 | { 120 | "id": "8a8b98b5-52d7-4480-9983-1f09f9e0bd5b", 121 | "name": "Maxwell Predovic II", 122 | "avatar": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 123 | }, 124 | { 125 | "id": "e800caed-40e5-47b1-be64-da445f78c395", 126 | "name": "Frederik Bernhard", 127 | "avatar": "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 128 | }, 129 | { 130 | "id": "35a46ffa-0622-44a9-b3dc-52554ca37be6", 131 | "name": "Mr. Aaliyah Parisian", 132 | "avatar": "https://images.unsplash.com/photo-1568409938619-12e139227838?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 133 | }, 134 | { 135 | "id": "0607c49a-140e-42c8-96c0-cb92347b1da7", 136 | "name": "Fidel Keebler", 137 | "avatar": "https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 138 | }, 139 | { 140 | "id": "7a929850-dfb3-4746-bdcb-3d708c63df99", 141 | "name": "Rosalind Monahan", 142 | "avatar": "https://images.unsplash.com/photo-1584486520270-19eca1efcce5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 143 | }, 144 | { 145 | "id": "dbea32bc-27da-4651-a7e9-9d0bc2616406", 146 | "name": "Serenity Lemke", 147 | "avatar": "https://images.unsplash.com/photo-1561505457-3bcad021f8ee?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 148 | } 149 | ] 150 | const selectedPerson = people.find(person => selectedPersonId === person.id) 151 | 152 | return ( 153 | 154 | {({ isOpen }) => ( 155 | 156 | 157 | Assign project to: 158 | 159 | 160 | {({ isFocused }) => ( 161 | 162 | 163 | { selectedPersonId ? ( 164 | 165 | {selectedPerson?.name} 166 | {selectedPerson?.name} 167 | 168 | ) : "Select a person..."} 169 | 170 | 171 | 172 | 173 | 174 | )} 175 | 176 | {isOpen && ( 177 | 178 | { people.map(person => ( 179 | 180 | {({ isActive, isSelected }) => ( 181 |
    182 | 183 | {person.name} 184 | {person.name} 185 | 186 | { isSelected && ( 187 | 188 | 189 | 190 | 191 | 192 | )} 193 |
    194 | )} 195 |
    196 | ))} 197 |
    198 | )} 199 |
    200 | )} 201 |
    202 | ) 203 | } 204 | 205 | export const Multiselect = (): React.ReactElement => { 206 | const [selectedWrestlers, setSelectedWrestlers] = React.useState([]) 207 | 208 | return ( 209 | 210 | {({ isOpen }) => ( 211 | 212 | 213 | Select wrestlers: 214 | 215 | 216 | {({ isFocused }) => ( 217 | 218 | { selectedWrestlers.length ? selectedWrestlers.join(", ") : "Select wrestlers" } 219 | 220 | 221 | 222 | 223 | )} 224 | 225 | {isOpen && ( 226 | 227 | { wrestlers.map(wrestler => ( 228 | 229 | {({ isActive, isSelected }) => ( 230 |
    231 | { wrestler } 232 | { isSelected && ( 233 | 234 | 235 | 236 | 237 | 238 | )} 239 |
    240 | )} 241 |
    242 | ))} 243 |
    244 | )} 245 |
    246 | )} 247 |
    248 | ) 249 | } 250 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

    2 | React Listbox 3 |

    4 | 5 |

    6 | A React implementation to the Vue Listbox component designed by TailwindLabs 7 |

    8 | 9 |

    10 | 11 | 12 | 13 |

    14 | 15 | --- 16 | 17 | React Listbox is a context driven component system that allows developers to create a Listbox built with React. 18 | 19 | Personally, I use a CSS framework called TailwindCSS created by the team at TailwindLabs. Tailwind CSS is a highly customizable, low-level CSS framework that gives you all of the building blocks you need to build bespoke designs without any annoying opinionated styles you have to fight to override. 20 | 21 | Recently the developers at TailwindLabs implemented a Listbox component API (just like this one) built for Vue developers. They promised that they would begin working on a React implementation of the Listbox soon, but I couldn't wait. In the meantime I've created this solution, maybe it will help you too. 22 | 23 | Made with love ❤️ 24 | 25 | _**Note: This solution comes completely unstyled. You will need to style it yourself.**_ 26 | 27 | ### Demo 28 | https://react-listbox.vercel.app 29 | 30 | ### Getting Started 31 | This package is meant to work alongisde any React application. Simply add the package to your list of dependencies, and make awesome projects 😎. 32 | 33 | ```bash 34 | yarn add @mitchbne/react-listbox 35 | ``` 36 | 37 | ### To Do 38 | - [x] Create a JSX replication of TailwindLab's Vue Listbox solution. 39 | - [x] Add Typescript support for components. 40 | - [x] Turn the components into an installable library. 41 | - [x] Home (key) moves the focus and activeItem to the first option. 42 | - [x] End (key) moves the focus and activeItem to the last option. 43 | - [x] Selects/focus the first selected option when opened (`if activeItem == null`) 44 | - [x] Create a multi-select solution. 45 | - [x] Multi selects focus the first selected option when opened 46 | - [x] Shift + Down Arrow: Moves focus to and toggles the selected state of the next option. 47 | - [x] Shift + Up Arrow: Moves focus to and toggles the selected state of the previous option. 48 | - [x] Shift + Space: Selects contiguous items from the most recently selected item to the focused item. 49 | - [x] Control + Shift + Home: Selects the focused option and all options up to the first option. Optionally, moves focus to the first option. 50 | - [x] Control + Shift + End: Selects the focused option and all options down to the last option. Optionally, moves focus to the last option. 51 | - [x] Control + A: Selects all options in the list. Optionally, if all options are selected, it may also unselect all options. 52 | - [ ] Add support for the ListboxList component to be a React Portal. 53 | - [ ] Handle disabled ListboxOption 54 | - [ ] Create an input-filter solution. 55 | 56 | ### Basic Example 57 | ```jsx 58 | import React, { useState, Fragment } from "react" 59 | import { Listbox, ListboxLabel, ListboxButton, ListboxList, ListboxOption } from "@mitchbne/react-listbox" 60 | 61 | export const SelectMenu = () => { 62 | const [selectedOption, setSelectedOption] = useState("Option 1") 63 | const options = [ 64 | "Option 1", 65 | "Option 2", 66 | "Option 3", 67 | ] 68 | 69 | return ( 70 | 71 | 72 | 73 | Select an option: 74 | 75 | 76 | { selectedOption ? selectedOption : "Select an option" } 77 | 78 | 79 | { options.map(option => ( 80 | {option} 81 | ))} 82 | 83 | )} 84 | 85 | )} 86 | 87 | ) 88 | } 89 | ``` 90 | ### TailwindCSS Example 91 | ```jsx 92 | import React, { useState, Fragment } from "react" 93 | import { Listbox, ListboxLabel, ListboxButton, ListboxList, ListboxOption } from "@mitchbne/react-listbox" 94 | 95 | export const SelectMenu = () => { 96 | const [selectedWrestler, setSelectedWrestler] = useState("Ric Flair") 97 | const wrestlers = [ 98 | "Stone Cold Steven Austin", 99 | "Bret Hart", 100 | "Ric Flair", 101 | "Macho Man Randy Savage", 102 | "Jake The Snake Roberts", 103 | "The Undertaker", 104 | "Hulk Hogan", 105 | "Rikishi", 106 | "John Cena", 107 | "Shawn Micahels", 108 | "British Bulldog", 109 | "Superfly Jimmy Snuka", 110 | "The Ultimate Warrior", 111 | "Andre The Giant", 112 | "Doink The Clown", 113 | ] 114 | 115 | return ( 116 | 117 | {({ isOpen }) => ( 118 | 119 | 120 | Select a wrestler: 121 | 122 | 123 | {({ isFocused }) => ( 124 | 125 | { selectedWrestler ? selectedWrestler : "Select a wrestler" } 126 | 127 | 128 | 129 | 130 | )} 131 | 132 | {isOpen && ( 133 | 134 | { wrestlers.map(wrestler => ( 135 | 136 | {({ isActive, isSelected }) => ( 137 |
    138 | {wrestler} 139 | { isSelected && ( 140 | 141 | 142 | 143 | 144 | 145 | )} 146 |
    147 | )} 148 |
    149 | ))} 150 |
    151 | )} 152 |
    153 | )} 154 |
    155 | ) 156 | } 157 | 158 | export default null 159 | ``` 160 | 161 | ### Advanced TailwindCSS Example 162 | ```jsx 163 | import React, { useState, Fragment } from "react" 164 | import { Listbox, ListboxLabel, ListboxButton, ListboxList, ListboxOption } from "@mitchbne/react-listbox" 165 | 166 | export const AlternativeSelectMenu = (): React.ReactNode => { 167 | const [selectedPersonId, setSelectedPersonId] = useState("2f8807fd-f9ec-4b52-ad01-51f9d714e3d2") 168 | const people = [ 169 | { 170 | "id": "5bbb4afc-d23d-4f33-b84a-251f0aafe8d4", 171 | "name": "Mr. Louisa Durgan", 172 | "avatar": "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 173 | }, 174 | { 175 | "id": "807e05d8-0896-42e0-9f9f-12c493be0da5", 176 | "name": "Maudie Collier II", 177 | "avatar": "https://images.unsplash.com/photo-1491528323818-fdd1faba62cc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 178 | }, 179 | { 180 | "id": "2f8807fd-f9ec-4b52-ad01-51f9d714e3d2", 181 | "name": "Torrance Kuvalis", 182 | "avatar": "https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 183 | }, 184 | { 185 | "id": "7b90f1de-cd62-4cef-84ea-9900dc42ff94", 186 | "name": "Ansley Ferry", 187 | "avatar": "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2.25&w=256&h=256&q=80" 188 | }, 189 | { 190 | "id": "387416e6-dcd0-4acc-a33b-1a3045bbd00c", 191 | "name": "Tyree Ortiz", 192 | "avatar": "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 193 | }, 194 | { 195 | "id": "8a8b98b5-52d7-4480-9983-1f09f9e0bd5b", 196 | "name": "Maxwell Predovic II", 197 | "avatar": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 198 | }, 199 | { 200 | "id": "e800caed-40e5-47b1-be64-da445f78c395", 201 | "name": "Frederik Bernhard", 202 | "avatar": "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 203 | }, 204 | { 205 | "id": "35a46ffa-0622-44a9-b3dc-52554ca37be6", 206 | "name": "Mr. Aaliyah Parisian", 207 | "avatar": "https://images.unsplash.com/photo-1568409938619-12e139227838?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 208 | }, 209 | { 210 | "id": "0607c49a-140e-42c8-96c0-cb92347b1da7", 211 | "name": "Fidel Keebler", 212 | "avatar": "https://images.unsplash.com/photo-1531427186611-ecfd6d936c79?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 213 | }, 214 | { 215 | "id": "7a929850-dfb3-4746-bdcb-3d708c63df99", 216 | "name": "Rosalind Monahan", 217 | "avatar": "https://images.unsplash.com/photo-1584486520270-19eca1efcce5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 218 | }, 219 | { 220 | "id": "dbea32bc-27da-4651-a7e9-9d0bc2616406", 221 | "name": "Serenity Lemke", 222 | "avatar": "https://images.unsplash.com/photo-1561505457-3bcad021f8ee?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" 223 | } 224 | ] 225 | const selectedPerson = people.find(person => selectedPersonId === person.id) 226 | 227 | return ( 228 | 229 | {({ isOpen }) => ( 230 | 231 | 232 | Assign project to: 233 | 234 | 235 | {({ isFocused }) => ( 236 | 237 | 238 | { selectedPersonId ? ( 239 | 240 | {selectedPerson?.name} 241 | {selectedPerson?.name} 242 | 243 | ) : "Select a person..."} 244 | 245 | 246 | 247 | 248 | 249 | )} 250 | 251 | {isOpen && ( 252 | 253 | { people.map(person => ( 254 | 255 | {({ isActive, isSelected }) => ( 256 |
    257 | 258 | {person.name} 259 | {person.name} 260 | 261 | { isSelected && ( 262 | 263 | 264 | 265 | 266 | 267 | )} 268 |
    269 | )} 270 |
    271 | ))} 272 |
    273 | )} 274 |
    275 | )} 276 |
    277 | ) 278 | } 279 | 280 | export default null 281 | ``` 282 | --------------------------------------------------------------------------------