├── app
├── vite-env.d.ts
├── main.tsx
├── Footer.tsx
├── App.tsx
├── Profiler.tsx
├── Examples
│ ├── BasicMode
│ │ ├── index.tsx
│ │ └── styles.css
│ ├── RefineMode
│ │ ├── index.tsx
│ │ └── styles.css
│ └── EventsChildren
│ │ ├── styles.css
│ │ └── index.tsx
├── favicon.svg
├── styles
│ ├── reset.css
│ └── app.css
├── Header.tsx
└── Section.tsx
├── .husky
└── pre-commit
├── src
├── index.ts
├── useLocalizedList.ts
├── utils.ts
├── types.ts
├── domains.json
└── Email.tsx
├── .gitignore
├── .prettierrc
├── cypress
└── support
│ ├── component-index.html
│ ├── component.ts
│ ├── cypress.d.ts
│ └── commands.ts
├── cypress.config.ts
├── index.html
├── tsconfig.json
├── .github
└── workflows
│ └── tests.yml
├── .eslintrc.json
├── tests
├── useLocalizedList.tsx
├── Email.tsx
├── useLocalizedList.cy.tsx
└── Email.cy.tsx
├── LICENSE
├── vite.config.mts
├── package.json
└── README.md
/app/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx lint-staged
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { default as domains } from './domains.json'
2 | export { useLocalizedList } from './useLocalizedList'
3 | export { Email } from './Email'
4 |
5 | export * from './types'
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
4 | .vscode/*
5 | !.vscode/extensions.json
6 | .DS_Store
7 |
8 | cypress/downloads
9 | cypress/videos
10 | cypress/screenshots
11 |
12 | *.tgz
13 | .eslintcache
14 |
15 | pnpm-lock.yaml
16 |
--------------------------------------------------------------------------------
/app/main.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client'
2 | import { App } from './App'
3 |
4 | import './styles/reset.css'
5 | import './styles/app.css'
6 |
7 | const root = createRoot(document.getElementById('root') as HTMLElement)
8 |
9 | root.render()
10 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "singleQuote": true,
4 | "printWidth": 120,
5 | "useTabs": false,
6 | "tabWidth": 3,
7 | "semi": false,
8 | "overrides": [
9 | {
10 | "files": "README.md",
11 | "options": {
12 | "printWidth": 80,
13 | "tabWidth": 2
14 | }
15 | }
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/cypress/support/component-index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | React Email Autocomplete
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/app/Footer.tsx:
--------------------------------------------------------------------------------
1 | export function Footer() {
2 | return (
3 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/cypress/support/component.ts:
--------------------------------------------------------------------------------
1 | import 'cypress-real-events'
2 | import 'cypress-axe'
3 | import './commands'
4 |
5 | export function getRandomInt(min: number, max: number) {
6 | min = Math.ceil(min)
7 | max = Math.floor(max)
8 | return Math.floor(Math.random() * (max - min) + min)
9 | }
10 |
11 | export function getRandomIndex(length: number) {
12 | return Math.floor(Math.random() * length)
13 | }
14 |
--------------------------------------------------------------------------------
/cypress.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'cypress'
2 |
3 | export default defineConfig({
4 | component: {
5 | specPattern: '**/*.cy.tsx',
6 | devServer: {
7 | framework: 'react',
8 | bundler: 'vite',
9 | },
10 | video: false,
11 | setupNodeEvents(on) {
12 | on('task', {
13 | log(message) {
14 | console.log(message)
15 | return null
16 | },
17 | })
18 | },
19 | },
20 | })
21 |
--------------------------------------------------------------------------------
/cypress/support/cypress.d.ts:
--------------------------------------------------------------------------------
1 | import { mount } from 'cypress/react'
2 |
3 | declare global {
4 | namespace Cypress {
5 | interface Chainable {
6 | mount: typeof mount
7 | downArrow(repeat: number): Chainable
8 | upArrow(repeat: number): Chainable
9 | setNavigatorLang(value: string): Chainable
10 | getSuggestions(selector: string, username: string): Chainable
11 | withinRoot(fn: () => void): Chainable
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | React Email Autocomplete
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/App.tsx:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect } from 'react'
2 | import { Header } from './Header'
3 | import { Footer } from './Footer'
4 | import { BasicMode } from './Examples/BasicMode'
5 | import { RefineMode } from './Examples/RefineMode'
6 | import { EventsChildren } from './Examples/EventsChildren'
7 |
8 | export function App() {
9 | useLayoutEffect(() => {
10 | document.getElementsByTagName('input')[0].focus()
11 | }, [])
12 |
13 | return (
14 | <>
15 |
16 |
17 |
18 |
19 |
20 | >
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/app/Profiler.tsx:
--------------------------------------------------------------------------------
1 | import { Profiler as ReactProfiler, ProfilerOnRenderCallback } from 'react'
2 |
3 | const onRender: ProfilerOnRenderCallback = (_, phase, actualDuration, startTime, commitTime) => {
4 | const performanceData = [
5 | `phase: ${phase}`,
6 | `actualDuration: ${actualDuration}`,
7 | `startTime: ${startTime}`,
8 | `commitTime: ${commitTime}`,
9 | ].join(', ')
10 | console.info(performanceData)
11 | }
12 |
13 | type JSX = {
14 | children: JSX.Element
15 | }
16 |
17 | export const Profiler = ({ children }: JSX) => (
18 |
19 | {children}
20 |
21 | )
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ES2020"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "module": "ES2020",
12 | "moduleResolution": "Node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 | "types": ["vitest/importMeta", "node"]
18 | },
19 | "include": ["app", "src", "cypress", "tests", "cypress/support/cypress.d.ts", "cypress-axe"]
20 | }
21 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on:
4 | pull_request:
5 | push:
6 | workflow_call:
7 |
8 | jobs:
9 | cypress-run:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - name: Install Node.js
14 | uses: actions/setup-node@v3
15 | with:
16 | node-version: 20
17 | - uses: pnpm/action-setup@v2
18 | name: Install pnpm
19 | with:
20 | version: 8
21 | run_install: true
22 | - name: Test with Cypress
23 | uses: cypress-io/github-action@v5
24 | with:
25 | env: CI=true
26 | component: true
27 | install: true
28 | browser: chrome
29 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": [
7 | "eslint:recommended",
8 | "plugin:react/recommended",
9 | "plugin:@typescript-eslint/recommended",
10 | "plugin:react-hooks/recommended",
11 | "plugin:react/jsx-runtime"
12 | ],
13 | "settings": {
14 | "react": {
15 | "version": "detect"
16 | }
17 | },
18 | "overrides": [],
19 | "parser": "@typescript-eslint/parser",
20 | "parserOptions": {
21 | "ecmaVersion": "latest",
22 | "sourceType": "module"
23 | },
24 | "plugins": ["react", "@typescript-eslint"],
25 | "rules": {
26 | "@typescript-eslint/no-non-null-assertion": "off",
27 | "@typescript-eslint/no-empty-function": "off"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tests/useLocalizedList.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Email as EmailComponent } from '../src/Email'
3 | import { useLocalizedList } from '../src/useLocalizedList'
4 |
5 | import { EmailProps, LocalizedList } from '../src/types'
6 |
7 | export const lists = {
8 | it: ['it-1.com', 'it-2.com', 'it-3.com', 'it-4.com'],
9 | 'it-CH': ['ch-1.com', 'ch-2.com', 'ch-3.com', 'ch-4.com'],
10 | default: ['gmail.com', 'yahoo.com', 'hotmail.com', 'aol.com', 'msn.com'],
11 | }
12 |
13 | type Props = { lists: LocalizedList; id: EmailProps['id'] }
14 |
15 | export function Email({ id, lists }: Props) {
16 | const [email, setEmail] = useState('')
17 | const baseList = useLocalizedList(lists)
18 |
19 | return
20 | }
21 |
--------------------------------------------------------------------------------
/cypress/support/commands.ts:
--------------------------------------------------------------------------------
1 | import { mount } from 'cypress/react'
2 |
3 | Cypress.Commands.add('mount', mount)
4 |
5 | Cypress.Commands.add('downArrow', (repeat: number) => {
6 | cy.realType('{downarrow}'.repeat(repeat))
7 | })
8 |
9 | Cypress.Commands.add('upArrow', (repeat: number) => {
10 | cy.realType('{uparrow}'.repeat(repeat))
11 | })
12 |
13 | Cypress.Commands.add('setNavigatorLang', (value: string) => {
14 | cy.window().then((window) => {
15 | Object.defineProperty(window.navigator, 'language', {
16 | value,
17 | configurable: true,
18 | })
19 | })
20 | })
21 |
22 | Cypress.Commands.add('getSuggestions', (selector: string, username: string) => {
23 | cy.get(selector).type(username)
24 | cy.get('li')
25 | })
26 |
27 | Cypress.Commands.add('withinRoot', (fn: () => void) => {
28 | cy.get('.Root').within(fn)
29 | })
30 |
--------------------------------------------------------------------------------
/src/useLocalizedList.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import { LocalizedList } from './types'
3 |
4 | /**
5 | * Hook to automatically inject localized lists according to user's browser locale.
6 | *
7 | * Read the documentation at: https://github.com/smastrom/react-email-autocomplete.
8 | */
9 | export function useLocalizedList(lists: LocalizedList, appLocale?: string) {
10 | const userLocale = appLocale || navigator?.language
11 | const [list, setList] = useState(lists.default)
12 |
13 | useEffect(() => {
14 | const exactLocaleList = lists[userLocale]
15 | if (exactLocaleList) return setList(exactLocaleList)
16 |
17 | const langCode = userLocale.split(/[-_]/)[0]
18 | const langCodeList = lists[langCode]
19 | if (langCodeList) return setList(langCodeList)
20 | }, [userLocale, lists])
21 |
22 | return list
23 | }
24 |
--------------------------------------------------------------------------------
/app/Examples/BasicMode/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Email } from '../../../src'
3 | import { Section } from '../../Section'
4 |
5 | import './styles.css'
6 |
7 | const classes = {
8 | wrapper: 'basicWrapper',
9 | dropdown: 'basicDropdown',
10 | input: 'basicInput',
11 | suggestion: 'basicSuggestion',
12 | domain: 'basicDomain',
13 | }
14 |
15 | const baseList = ['gmail.com', 'yahoo.com', 'hotmail.com', 'aol.com', 'msn.com', 'proton.me']
16 |
17 | export function BasicMode() {
18 | const [email, setEmail] = useState('')
19 |
20 | return (
21 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/app/favicon.svg:
--------------------------------------------------------------------------------
1 |
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2023-present Simone Mastromattei
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/app/Examples/RefineMode/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Email, domains } from '../../../src'
3 | import { Section } from '../../Section'
4 |
5 | import './styles.css'
6 |
7 | const classes = {
8 | wrapper: 'refineWrapper',
9 | dropdown: 'refineDropdown',
10 | input: 'refineInput',
11 | suggestion: 'refineSuggestion',
12 | domain: 'refineDomain',
13 | }
14 |
15 | const baseList = ['gmail.com', 'yahoo.com', 'hotmail.com', 'aol.com', 'msn.com', 'proton.me']
16 |
17 | export function RefineMode() {
18 | const [email, setEmail] = useState('')
19 |
20 | return (
21 |
22 |
23 |
24 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/tests/Email.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { Email as EmailComponent } from '../src/Email'
3 | import { EmailProps } from '../src/types'
4 |
5 | const internalBaseList = ['gmail.com', 'yahoo.com', 'hotmail.com', 'aol.com', 'outlook.com', 'proton.me']
6 |
7 | export function Email({ baseList, ...props }: Partial) {
8 | const [email, setEmail] = useState('')
9 |
10 | const [focusCount, setFocusCount] = useState({
11 | focus: 0,
12 | blur: 0,
13 | })
14 |
15 | function handleFocus(type: keyof typeof focusCount) {
16 | setFocusCount((prevCount) => ({ ...prevCount, [type]: prevCount[type] + 1 }))
17 | }
18 |
19 | return (
20 | <>
21 | handleFocus('focus')}
27 | onBlur={() => handleFocus('blur')}
28 | {...props}
29 | />
30 |
31 | >
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/app/styles/reset.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | ::before,
6 | ::after {
7 | border-width: 0;
8 | border-style: solid;
9 | border-color: currentColor;
10 | }
11 |
12 | body,
13 | html {
14 | margin: 0;
15 | line-height: inherit;
16 | }
17 |
18 | a {
19 | color: inherit;
20 | text-decoration: inherit;
21 | }
22 |
23 | b,
24 | strong {
25 | font-weight: bolder;
26 | }
27 |
28 | small {
29 | font-size: 0.875rem;
30 | }
31 |
32 | button,
33 | input,
34 | optgroup,
35 | select,
36 | textarea {
37 | font-family: inherit; /* 1 */
38 | font-size: 100%; /* 1 */
39 | font-weight: inherit; /* 1 */
40 | line-height: inherit; /* 1 */
41 | color: inherit; /* 1 */
42 | margin: 0; /* 2 */
43 | padding: 0; /* 3 */
44 | }
45 |
46 | blockquote,
47 | dl,
48 | dd,
49 | h1,
50 | h2,
51 | h3,
52 | h4,
53 | h5,
54 | h6,
55 | hr,
56 | figure,
57 | p,
58 | pre {
59 | margin: 0;
60 | }
61 |
62 | ol,
63 | ul,
64 | menu {
65 | list-style: none;
66 | margin: 0;
67 | padding: 0;
68 | }
69 |
70 | button,
71 | [role='button'] {
72 | cursor: pointer;
73 | }
74 |
75 | :disabled {
76 | cursor: default;
77 | }
78 |
79 | [hidden] {
80 | display: none;
81 | }
82 |
--------------------------------------------------------------------------------
/vite.config.mts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 |
3 | import dts from 'vite-plugin-dts'
4 | import react from '@vitejs/plugin-react'
5 | import Package from './package.json'
6 |
7 | export default defineConfig(({ command, mode }) => {
8 | if (mode === 'app') {
9 | return {
10 | plugins: [react()],
11 | }
12 | }
13 | return {
14 | define: {
15 | ...(command === 'build' ? { 'import.meta.vitest': 'undefined' } : {}),
16 | },
17 | test: {
18 | includeSource: ['src/utils.ts'],
19 | },
20 | build: {
21 | lib: {
22 | name: Package.name,
23 | entry: 'src/index.ts',
24 | formats: ['es', 'cjs'],
25 | fileName: 'index',
26 | },
27 | rollupOptions: {
28 | external: ['react', 'react-dom', 'react/jsx-runtime'],
29 | output: {
30 | banner: '"use client";',
31 | globals: {
32 | react: 'React',
33 | 'react/jsx-runtime': 'React',
34 | },
35 | },
36 | },
37 | },
38 | plugins: [
39 | dts({
40 | rollupTypes: true,
41 | }),
42 | ],
43 | }
44 | })
45 |
--------------------------------------------------------------------------------
/app/Header.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-no-target-blank */
2 |
3 | export function Header() {
4 | return (
5 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/app/Section.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-no-target-blank */
2 | const path = 'https://github.com/smastrom/react-email-autocomplete/tree/main/app/Examples/'
3 |
4 | type Props = {
5 | children: React.ReactNode
6 | name: string
7 | folderName: string
8 | className?: string
9 | }
10 |
11 | export function Section({ children, name, folderName, className = '' }: Props) {
12 | return (
13 |
14 |
15 |
35 |
{children}
36 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/app/Examples/BasicMode/styles.css:
--------------------------------------------------------------------------------
1 | .basicWrapper {
2 | --accentColor: #0062ff;
3 | --textColor: #475569;
4 | --selectionColor: #f7f8ff;
5 | --shadowColor: rgba(99, 99, 99, 0.2);
6 | --transparentColor: rgba(0, 0, 0, 0);
7 | color: var(--textColor);
8 | width: 300px;
9 | position: relative;
10 | max-width: 100%;
11 | }
12 |
13 | .basicInput {
14 | width: 100%;
15 | border: none;
16 | border-radius: 0;
17 | font-weight: 600;
18 | padding: 0.4em 0.6em;
19 | border-bottom: 2px solid #dedede;
20 | transition:
21 | border-color 100ms ease-in-out,
22 | background-color 100ms ease-in-out,
23 | color 100ms ease-in-out;
24 | }
25 |
26 | .basicInput[aria-expanded='true'],
27 | .basicInput:hover,
28 | .basicInput:focus {
29 | border-bottom: 2px solid var(--accentColor);
30 | }
31 |
32 | .basicInput:focus {
33 | outline: none;
34 | }
35 |
36 | .basicDropdown {
37 | width: 100%;
38 | box-shadow: var(--shadowColor) 0px 2px 8px 0px;
39 | position: absolute;
40 | list-style-type: none;
41 | background-color: white;
42 | z-index: 999;
43 | margin-top: 0;
44 | margin-bottom: 0;
45 | }
46 |
47 | .basicSuggestion {
48 | -webkit-tap-highlight-color: var(--transparentColor);
49 | padding: 0.3em 0.6em;
50 | cursor: pointer;
51 | }
52 |
53 | .basicSuggestion:hover,
54 | .basicSuggestion:focus,
55 | .basicSuggestion:focus-visible {
56 | outline: none;
57 | }
58 |
59 | .basicSuggestion[data-active-email='true'] {
60 | background-color: var(--selectionColor);
61 | }
62 |
63 | .basicDomain {
64 | font-weight: 600;
65 | margin-left: 0.1em;
66 | color: var(--accentColor);
67 | }
68 |
--------------------------------------------------------------------------------
/tests/useLocalizedList.cy.tsx:
--------------------------------------------------------------------------------
1 | import { Email, lists } from './useLocalizedList'
2 |
3 | const username = 'myusername'
4 | const INPUT_ID = 'InputId'
5 |
6 | describe('Navigator language without country code - [it]', () => {
7 | beforeEach(() => {
8 | cy.setNavigatorLang('it')
9 | })
10 |
11 | it('Should display it domains', () => {
12 | cy.mount()
13 |
14 | cy.getSuggestions(`#${INPUT_ID}`, username).each((li, index) => {
15 | expect(li.text()).to.be.eq(`${username}@${lists.it[index]}`)
16 | })
17 | })
18 | })
19 |
20 | describe('Navigator language with country code - [it-CH]', () => {
21 | beforeEach(() => {
22 | cy.setNavigatorLang('it-CH')
23 | })
24 |
25 | it('Should display it-CH domains', () => {
26 | cy.mount()
27 |
28 | cy.getSuggestions(`#${INPUT_ID}`, username).each((li, index) => {
29 | expect(li.text()).to.be.eq(`${username}@${lists['it-CH'][index]}`)
30 | })
31 | })
32 |
33 | it('Should display it domains if it-CH not included in lang list', () => {
34 | const { it, default: fallback } = lists
35 | const newList = { it, default: fallback }
36 |
37 | cy.mount()
38 |
39 | cy.getSuggestions(`#${INPUT_ID}`, username).each((li, index) => {
40 | expect(li.text()).to.be.eq(`${username}@${newList.it[index]}`)
41 | })
42 | })
43 |
44 | it('Should display default domains if no it included in lang list', () => {
45 | const { default: fallback } = lists
46 | const newList = { default: fallback }
47 |
48 | cy.mount()
49 |
50 | cy.getSuggestions(`#${INPUT_ID}`, username).each((li, index) => {
51 | expect(li.text()).to.be.eq(`${username}@${newList.default[index]}`)
52 | })
53 | })
54 | })
55 |
56 | describe('Unknown navigator language - [de-DE]', () => {
57 | beforeEach(() => {
58 | cy.setNavigatorLang('de-DE')
59 | })
60 |
61 | it('Should display default domains', () => {
62 | cy.mount()
63 |
64 | cy.getSuggestions(`#${INPUT_ID}`, username).each((li, index) => {
65 | expect(li.text()).to.be.eq(`${username}@${lists.default[index]}`)
66 | })
67 | })
68 | })
69 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | export const alphanumericKeys = /^[a-z0-9@.]$/i
2 |
3 | export function cleanValue(value: string) {
4 | return value.replace(/\s+/g, '').toLowerCase()
5 | }
6 |
7 | export function getHonestValue(value: unknown, maxValue: number, defaultValue: number) {
8 | if (typeof value === 'number' && Number.isInteger(value) && value <= maxValue) {
9 | return value
10 | }
11 | return defaultValue
12 | }
13 |
14 | export function isFn(fn: unknown) {
15 | return typeof fn === 'function'
16 | }
17 |
18 | export function getEmailData(value: string, minChars: number) {
19 | const [username] = value.split('@')
20 | const breakpoint = value.indexOf('@')
21 | const domain = breakpoint >= 0 ? value.slice(breakpoint + 1) : '' // Domain is truthy only if typed @
22 |
23 | const hasUsername = username.length >= minChars
24 | const hasAt = hasUsername && value.includes('@')
25 | const hasDomain = hasUsername && domain.length >= 1
26 |
27 | return { username, domain, hasUsername, hasAt, hasDomain }
28 | }
29 |
30 | /**
31 | *
32 | *
33 | *
34 | *
35 | * getEmailData tests
36 | *
37 | *
38 | *
39 | *
40 | *
41 | */
42 |
43 | if (import.meta.vitest) {
44 | const { it, expect, describe } = import.meta.vitest
45 |
46 | describe('Domain', () => {
47 | it('Should return domain', () => {
48 | const { domain } = getEmailData('username@gmail.com', 2)
49 | expect(domain).toBe('gmail.com')
50 |
51 | const { domain: domain2 } = getEmailData('username@g', 2)
52 | expect(domain2).toBe('g')
53 | })
54 |
55 | it('Should return domain even if more @', () => {
56 | const { domain } = getEmailData('username@ciao@', 2)
57 | expect(domain).toBe('ciao@')
58 | })
59 |
60 | it('Should return empty string if no domain', () => {
61 | const { domain } = getEmailData('username@', 2)
62 | expect(domain).toBe('')
63 |
64 | const { domain: domain2 } = getEmailData('username', 2)
65 | expect(domain2).toBe('')
66 | })
67 | })
68 |
69 | describe('hasDomain', () => {
70 | it('Should return true', () => {
71 | const { hasDomain } = getEmailData('username@gmail.com', 2)
72 | expect(hasDomain).toBe(true)
73 | })
74 |
75 | it('Should return false', () => {
76 | const { hasDomain } = getEmailData('username@', 2)
77 | expect(hasDomain).toBe(false)
78 |
79 | const { hasDomain: hasDomain2 } = getEmailData('username', 2)
80 | expect(hasDomain2).toBe(false)
81 | })
82 | })
83 | }
84 |
--------------------------------------------------------------------------------
/app/Examples/RefineMode/styles.css:
--------------------------------------------------------------------------------
1 | .refineWrapper {
2 | --borderColor: #d5dde7;
3 | --borderAltColor: #e2e8f0;
4 | --accentColor: #ec4899;
5 | --accentAltColor: #fbcfe8;
6 | --textColor: #475569;
7 | --borderColor: #cbd5e1;
8 | --transparentColor: rgba(0, 0, 0, 0);
9 | color: var(--textColor);
10 | width: 300px;
11 | position: relative;
12 | max-width: 100%;
13 | }
14 |
15 | .refineInput {
16 | font-weight: 600;
17 | width: 100%;
18 | padding: 0.4em 0.6em;
19 | border: 2px solid var(--borderColor);
20 | border-radius: 10px;
21 | transition:
22 | border 100ms ease-out,
23 | box-shadow 100ms ease-out;
24 | }
25 |
26 | .refineInput:hover,
27 | .refineInput[aria-expanded='true'] {
28 | border: 2px solid var(--accentColor);
29 | }
30 |
31 | .refineInput:focus {
32 | border: 2px solid var(--accentColor);
33 | box-shadow: 0px 0px 0px 3px var(--accentAltColor);
34 | outline: none;
35 | }
36 |
37 | .refineDropdown {
38 | width: 100%;
39 | animation: slideIn 200ms ease-out;
40 | margin-top: 5px;
41 | margin-bottom: 5px;
42 | position: absolute;
43 | z-index: 999;
44 | border-radius: 10px;
45 | border: 2px solid var(--borderColor);
46 | background-color: white;
47 | }
48 |
49 | .refineSuggestion {
50 | width: 100%;
51 | cursor: pointer;
52 | user-select: none;
53 | -webkit-tap-highlight-color: var(--transparentColor);
54 | overflow: hidden;
55 | padding: 0.4em 0.6em;
56 | border-bottom: 1px solid var(--borderAltColor);
57 | transition:
58 | background-color 50ms ease-out,
59 | color 50ms ease-out;
60 | }
61 |
62 | .refineSuggestion:only-child {
63 | border-radius: 8px !important;
64 | }
65 |
66 | .refineSuggestion:first-of-type {
67 | border-radius: 8px 8px 0 0;
68 | }
69 |
70 | .refineSuggestion:last-of-type {
71 | border-bottom: 0;
72 | border-radius: 0 0 8px 8px;
73 | }
74 |
75 | .refineSuggestion[data-active-email='true'] {
76 | background-color: var(--accentColor);
77 | color: white;
78 | outline: none;
79 | }
80 |
81 | .refineSuggestion:hover,
82 | .refineSuggestion:focus,
83 | .refineSuggestion:focus-visible {
84 | outline: none;
85 | }
86 |
87 | .refineUsername {
88 | font-weight: 400;
89 | padding-right: 0.1em;
90 | }
91 |
92 | .refineDomain {
93 | font-weight: 600;
94 | }
95 |
96 | @keyframes slideIn {
97 | 0% {
98 | opacity: 0;
99 | transform: translateY(-20px);
100 | }
101 |
102 | 100% {
103 | opacity: 1;
104 | transform: translateY(0px);
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@smastrom/react-email-autocomplete",
3 | "version": "1.2.0",
4 | "private": false,
5 | "homepage": "https://react-email-autocomplete.netlify.app",
6 | "bugs": {
7 | "url": "https://github.com/smastrom/react-email-autocomplete/issues"
8 | },
9 | "description": "Headless email input field with custom suggestions. Inspired by european flight booking websites.",
10 | "keywords": [
11 | "react",
12 | "react-email",
13 | "react-email-autocomplete",
14 | "react-autocomplete",
15 | "react-email-autocomplete",
16 | "react-email-component",
17 | "email-component",
18 | "email-autocomplete",
19 | "email-input",
20 | "email-suggestions",
21 | "email-autocomplete"
22 | ],
23 | "repository": {
24 | "type": "git",
25 | "url": "https://github.com/smastrom/react-email-autocomplete"
26 | },
27 | "license": "MIT",
28 | "author": "Simone Mastromattei ",
29 | "main": "dist/index.js",
30 | "module": "dist/index.mjs",
31 | "types": "dist/index.d.ts",
32 | "files": [
33 | "dist/*"
34 | ],
35 | "exports": {
36 | ".": {
37 | "import": "./dist/index.mjs",
38 | "require": "./dist/index.js",
39 | "types": "./dist/index.d.ts"
40 | }
41 | },
42 | "scripts": {
43 | "build": "tsc && vite build",
44 | "build:app": "tsc && vite build --mode app",
45 | "dev": "vite",
46 | "preview": "vite preview",
47 | "test:unit": "vitest",
48 | "test": "cypress run --component --browser chrome",
49 | "test:gui": "cypress open --component",
50 | "prepare": "husky install"
51 | },
52 | "lint-staged": {
53 | "*.{ts,tsx}": "eslint --cache --fix",
54 | "*.{ts,tsx,css,json,md}": "prettier --write"
55 | },
56 | "devDependencies": {
57 | "@types/node": "^20.10.4",
58 | "@types/react": "^18.2.43",
59 | "@types/react-dom": "^18.2.17",
60 | "@typescript-eslint/eslint-plugin": "^6.13.2",
61 | "@typescript-eslint/parser": "^6.13.2",
62 | "@vitejs/plugin-react": "^4.2.1",
63 | "axe-core": "^4.8.2",
64 | "cypress": "^13.6.1",
65 | "cypress-axe": "^1.5.0",
66 | "cypress-real-events": "^1.11.0",
67 | "eslint": "^8.55.0",
68 | "eslint-plugin-react": "^7.33.2",
69 | "eslint-plugin-react-hooks": "^4.6.0",
70 | "husky": "^8.0.3",
71 | "lint-staged": "^15.2.0",
72 | "prettier": "^3.1.0",
73 | "react": "^18.2.0",
74 | "react-dom": "^18.2.0",
75 | "typescript": "^5.3.3",
76 | "vite": "^5.0.7",
77 | "vite-plugin-dts": "^3.6.4",
78 | "vitest": "^1.0.4"
79 | },
80 | "peerDependencies": {
81 | "react": ">=18",
82 | "react-dom": ">=18"
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/app/Examples/EventsChildren/styles.css:
--------------------------------------------------------------------------------
1 | .eventsWrapper {
2 | --backgroundColor: #f4f4f7;
3 | --accentColor: #6e5bfe;
4 | --validAccentColor: #3bc100;
5 | --validBackgroundColor: #effaeb;
6 | --invalidAccentColor: #ff7272;
7 | --invalidBackgroundColor: #ffeded;
8 | --textColor: #475569;
9 | --transparentColor: rgba(0, 0, 0, 0);
10 | color: var(--textColor);
11 | width: 300px;
12 | max-width: 100%;
13 | position: relative;
14 | }
15 |
16 | .eventsInput {
17 | width: 100%;
18 | background-color: var(--backgroundColor);
19 | border: none;
20 | font-weight: 600;
21 | padding: 0.4em 2em 0.4em 0.6em;
22 | border-radius: 10px;
23 | border: 2px solid transparent;
24 | transition:
25 | border-color 100ms ease-in-out,
26 | background-color 100ms ease-in-out,
27 | color 100ms ease-in-out;
28 | }
29 |
30 | .eventsInput:hover,
31 | .eventsInput:focus,
32 | .eventsInput[aria-expanded='true'] {
33 | outline: none;
34 | color: var(--textColor);
35 | background-color: var(--backgroundColor);
36 | border: 2px solid var(--accentColor);
37 | }
38 |
39 | .eventsDropdown {
40 | width: 100%;
41 | position: absolute;
42 | z-index: 999;
43 | animation: fadeIn 200ms ease-in-out;
44 | margin-top: 5px;
45 | margin-bottom: 5px;
46 | background-color: var(--backgroundColor);
47 | padding: 10px;
48 | border-radius: 10px;
49 | }
50 |
51 | .eventsSuggestion {
52 | font-size: 95%;
53 | overflow: hidden;
54 | user-select: none;
55 | padding: 0.4em 0.6em;
56 | border-radius: 6px;
57 | background-color: transparent;
58 | -webkit-tap-highlight-color: var(--transparentColor);
59 | transition:
60 | color 100ms ease-in-out,
61 | background-color 100ms ease-in-out;
62 | }
63 |
64 | .eventsSuggestion:hover,
65 | .eventsSuggestion:focus,
66 | .eventsSuggestion:focus-visible {
67 | outline: none;
68 | }
69 |
70 | .eventsSuggestion[data-active-email='true'] {
71 | outline: none;
72 | background-color: white;
73 | cursor: pointer;
74 | color: var(--accentColor);
75 | }
76 |
77 | .eventsDomain {
78 | font-weight: 600;
79 | }
80 |
81 | @keyframes fadeIn {
82 | from {
83 | opacity: 0;
84 | }
85 | to {
86 | opacity: 1;
87 | }
88 | }
89 |
90 | /* Validity */
91 |
92 | .validInput {
93 | border: 2px solid var(--validAccentColor);
94 | color: var(--validAccentColor);
95 | background-color: var(--validBackgroundColor);
96 | }
97 |
98 | .invalidInput {
99 | border: 2px solid var(--invalidAccentColor);
100 | color: var(--invalidAccentColor);
101 | background-color: var(--invalidBackgroundColor);
102 | }
103 |
104 | /* Icons */
105 |
106 | .validIcon {
107 | position: absolute;
108 | right: 1em;
109 | height: 100%;
110 | }
111 |
112 | .eventsInput:hover ~ svg {
113 | display: none;
114 | }
115 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export type Maybe = T | null
4 |
5 | export enum Elements {
6 | Wrapper = 'wrapper',
7 | Input = 'input',
8 | Dropdown = 'dropdown',
9 | Suggestion = 'suggestion',
10 | Username = 'username',
11 | Domain = 'domain',
12 | }
13 |
14 | export type ClassNames = Partial>
15 |
16 | export type OnSelectData = {
17 | value: string
18 | keyboard: boolean
19 | position: number
20 | }
21 |
22 | export type Values = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
23 |
24 | export type OnChange = React.Dispatch> | ((newValue: string) => void | Promise)
25 | export type OnSelect = (object: OnSelectData) => void | Promise
26 |
27 | export type Props = {
28 | /** State or portion of state to hold the email. */
29 | value: string | undefined
30 | /** State setter or custom dispatcher to update the email. */
31 | onChange: OnChange
32 | /** Domains to suggest while typing the username. */
33 | baseList: string[]
34 | /** Domains to refine suggestions after typing @. */
35 | refineList?: string[]
36 | /** Custom callback to invoke on suggestion select. */
37 | onSelect?: OnSelect
38 | /** Minimum chars required to display suggestions. */
39 | minChars?: Values
40 | /** Maximum number of suggestions to display. */
41 | maxResults?: Omit
42 | /** Class names for each element. */
43 | classNames?: ClassNames
44 | /** Class name of the wrapper element. */
45 | className?: string
46 | /** Dropdown `aria-label` value */
47 | dropdownAriaLabel?: string
48 | /** Value of the `data-` attribute to be set on the focuesed/hovered suggestion. Defaults to `data-active-email`. */
49 | activeDataAttr?: `data-${string}`
50 | children?: React.ReactNode
51 | /** Dropdown placement.
52 | *
53 | * @deprecated Since version 1.0.0 dropdown is always placed below the input.
54 | */
55 | placement?: 'auto' | 'bottom'
56 | /** Custom prefix for dropdown unique ID.
57 | *
58 | * @deprecated Since version 1.2.0 it is generated automatically.
59 | */
60 | customPrefix?: string
61 | /** DOM ID of the wrapper element.
62 | *
63 | * @deprecated Since version 1.2.0
64 | */
65 | wrapperId?: string
66 | /** Input field validity state for assistive technologies.
67 | *
68 | * @deprecated Since version 1.2.0. Use `aria-invalid` instead.
69 | */
70 | isInvalid?: boolean
71 | }
72 |
73 | export type Events = {
74 | onFocus?: React.FocusEventHandler
75 | onBlur?: React.FocusEventHandler
76 | onKeyDown?: React.KeyboardEventHandler
77 | onInput?: React.FormEventHandler
78 | }
79 |
80 | export type InternalInputProps =
81 | | 'ref'
82 | | 'aria-expanded'
83 | | 'type'
84 | | 'role'
85 | | 'autoComplete'
86 | | 'aria-autocomplete'
87 | | 'aria-controls'
88 |
89 | export type EmailProps = Props &
90 | Events &
91 | Partial, keyof Props | keyof Events | InternalInputProps>>
92 |
93 | export type LocalizedList = {
94 | default: string[]
95 | } & Record
96 |
--------------------------------------------------------------------------------
/app/styles/app.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Titillium+Web:wght@400;600;700&display=swap');
2 |
3 | :root {
4 | --accentColor: #ec4899;
5 | --textColor: #475569;
6 | --headingsColor: #1f2937;
7 | }
8 |
9 | html {
10 | font-size: 100%;
11 | font-synthesis: none;
12 | text-rendering: optimizeLegibility;
13 | -webkit-font-smoothing: antialiased;
14 | -moz-osx-font-smoothing: grayscale;
15 | -webkit-text-size-adjust: 100%;
16 | color: var(--textColor);
17 | font-family:
18 | Titillium Web,
19 | Avenir,
20 | Helvetica,
21 | Arial,
22 | sans-serif;
23 | }
24 |
25 | #root {
26 | flex-direction: column;
27 | justify-content: space-between;
28 | align-items: center;
29 | display: flex;
30 | }
31 |
32 | svg {
33 | transition:
34 | fill 150ms ease-out,
35 | stroke 150ms ease-out;
36 | }
37 |
38 | label {
39 | font-size: 0.875rem;
40 | font-weight: 600;
41 | margin-bottom: 5px;
42 | color: var(--textColor);
43 | }
44 |
45 | input::placeholder {
46 | color: #999;
47 | }
48 |
49 | .header,
50 | .footer {
51 | padding: 20px;
52 | width: 460px;
53 | max-width: 100%;
54 | }
55 |
56 | .header {
57 | display: flex;
58 | justify-content: space-between;
59 | align-items: center;
60 | }
61 |
62 | @media (max-width: 810px) {
63 | .footer {
64 | margin-bottom: 140px;
65 | }
66 | }
67 |
68 | .title {
69 | line-height: 1.125;
70 | font-size: 1.5rem;
71 | color: var(--headingsColor);
72 | }
73 |
74 | .profileLink {
75 | color: var(--accentColor);
76 | font-weight: 600;
77 | }
78 |
79 | .profileLink:hover {
80 | text-decoration: underline;
81 | }
82 |
83 | .sectionWrapper {
84 | width: 100%;
85 | justify-content: center;
86 | display: flex;
87 | min-height: 200px;
88 | }
89 |
90 | .sectionContainer {
91 | padding: 0 20px 20px;
92 | justify-content: space-around;
93 | max-width: 100%;
94 | width: 460px;
95 | display: flex;
96 | flex-direction: column;
97 | }
98 |
99 | .sectionContainer header {
100 | display: flex;
101 | justify-content: space-between;
102 | align-items: center;
103 | }
104 |
105 | .sectionContainer header h1 {
106 | font-size: 1.25rem;
107 | color: var(--headingsColor);
108 | }
109 |
110 | .fieldWrapper {
111 | display: flex;
112 | flex-direction: column;
113 | width: 300px;
114 | max-width: 100%;
115 | }
116 |
117 | .basicSection {
118 | border-top: 1px solid #dae3ec;
119 | background-color: #f8fafc;
120 | }
121 |
122 | .refineSection {
123 | border-top: 1px solid #dae3ec;
124 | }
125 |
126 | .eventsSection {
127 | /* background: url(https://www.toptal.com/designers/subtlepatterns/uploads/dot-grid.png); */
128 | border-top: 1px solid #dae3ec;
129 | border-bottom: 1px solid #dae3ec;
130 | }
131 |
132 | .codeLink {
133 | width: 30px;
134 | }
135 |
136 | .githubLink svg {
137 | width: 30px;
138 | height: 30px;
139 | fill: var(--textColor);
140 | }
141 |
142 | .codeLink:hover svg {
143 | stroke: var(--accentColor);
144 | }
145 |
146 | .githubLink:hover svg {
147 | fill: var(--accentColor);
148 | }
149 |
150 | .codeIcon {
151 | margin: auto;
152 | display: flex;
153 | }
154 |
--------------------------------------------------------------------------------
/src/domains.json:
--------------------------------------------------------------------------------
1 | [
2 | "aol.com",
3 | "att.net",
4 | "comcast.net",
5 | "facebook.com",
6 | "gmail.com",
7 | "gmx.com",
8 | "googlemail.com",
9 | "google.com",
10 | "hotmail.com",
11 | "hotmail.co.uk",
12 | "mac.com",
13 | "me.com",
14 | "mail.com",
15 | "msn.com",
16 | "live.com",
17 | "sbcglobal.net",
18 | "verizon.net",
19 | "yahoo.com",
20 | "yahoo.co.uk",
21 | "email.com",
22 | "fastmail.fm",
23 | "games.com",
24 | "gmx.net",
25 | "hush.com",
26 | "hushmail.com",
27 | "icloud.com",
28 | "iname.com",
29 | "inbox.com",
30 | "lavabit.com",
31 | "love.com",
32 | "outlook.com",
33 | "pobox.com",
34 | "protonmail.ch",
35 | "protonmail.com",
36 | "tutanota.de",
37 | "tutanota.com",
38 | "tutamail.com",
39 | "tuta.io",
40 | "keemail.me",
41 | "rocketmail.com",
42 | "safe-mail.net",
43 | "wow.com",
44 | "ygm.com",
45 | "ymail.com",
46 | "zoho.com",
47 | "bellsouth.net",
48 | "charter.net",
49 | "cox.net",
50 | "earthlink.net",
51 | "juno.com",
52 | "btinternet.com",
53 | "virginmedia.com",
54 | "blueyonder.co.uk",
55 | "live.co.uk",
56 | "ntlworld.com",
57 | "orange.net",
58 | "sky.com",
59 | "talktalk.co.uk",
60 | "tiscali.co.uk",
61 | "virgin.net",
62 | "bt.com",
63 | "sina.com",
64 | "sina.cn",
65 | "qq.com",
66 | "naver.com",
67 | "hanmail.net",
68 | "daum.net",
69 | "nate.com",
70 | "yahoo.co.jp",
71 | "yahoo.co.kr",
72 | "yahoo.co.id",
73 | "yahoo.co.in",
74 | "yahoo.com.sg",
75 | "yahoo.com.ph",
76 | "163.com",
77 | "yeah.net",
78 | "126.com",
79 | "21cn.com",
80 | "aliyun.com",
81 | "foxmail.com",
82 | "hotmail.fr",
83 | "live.fr",
84 | "laposte.net",
85 | "yahoo.fr",
86 | "wanadoo.fr",
87 | "orange.fr",
88 | "gmx.fr",
89 | "sfr.fr",
90 | "neuf.fr",
91 | "free.fr",
92 | "gmx.de",
93 | "hotmail.de",
94 | "live.de",
95 | "online.de",
96 | "t-online.de",
97 | "web.de",
98 | "yahoo.de",
99 | "libero.it",
100 | "virgilio.it",
101 | "hotmail.it",
102 | "aol.it",
103 | "tiscali.it",
104 | "alice.it",
105 | "live.it",
106 | "yahoo.it",
107 | "email.it",
108 | "tin.it",
109 | "poste.it",
110 | "teletu.it",
111 | "bk.ru",
112 | "inbox.ru",
113 | "list.ru",
114 | "mail.ru",
115 | "rambler.ru",
116 | "yandex.by",
117 | "yandex.com",
118 | "yandex.kz",
119 | "yandex.ru",
120 | "yandex.ua",
121 | "ya.ru",
122 | "hotmail.be",
123 | "live.be",
124 | "skynet.be",
125 | "voo.be",
126 | "tvcablenet.be",
127 | "telenet.be",
128 | "hotmail.com.ar",
129 | "live.com.ar",
130 | "yahoo.com.ar",
131 | "fibertel.com.ar",
132 | "speedy.com.ar",
133 | "arnet.com.ar",
134 | "yahoo.com.mx",
135 | "live.com.mx",
136 | "hotmail.es",
137 | "hotmail.com.mx",
138 | "prodigy.net.mx",
139 | "yahoo.ca",
140 | "hotmail.ca",
141 | "bell.net",
142 | "shaw.ca",
143 | "sympatico.ca",
144 | "rogers.com",
145 | "yahoo.com.br",
146 | "hotmail.com.br",
147 | "outlook.com.br",
148 | "uol.com.br",
149 | "bol.com.br",
150 | "terra.com.br",
151 | "ig.com.br",
152 | "r7.com",
153 | "zipmail.com.br",
154 | "globo.com",
155 | "globomail.com",
156 | "oi.com.br"
157 | ]
158 |
--------------------------------------------------------------------------------
/app/Examples/EventsChildren/index.tsx:
--------------------------------------------------------------------------------
1 | import { SVGAttributes, useLayoutEffect, useRef, useState } from 'react'
2 | import { Email, domains } from '../../../src'
3 | import { OnSelectData } from '../../../src/types'
4 | import { Section } from '../../Section'
5 |
6 | import './styles.css'
7 |
8 | enum Valididty {
9 | IDLE,
10 | VALID,
11 | INVALID,
12 | }
13 |
14 | function testEmail(value: string) {
15 | return /^\w+@[a-zA-Z.,]+?\.[a-zA-Z]{2,3}$/.test(value)
16 | }
17 |
18 | const classes = {
19 | wrapper: 'eventsWrapper',
20 | dropdown: 'eventsDropdown',
21 | suggestion: 'eventsSuggestion',
22 | domain: 'eventsDomain',
23 | }
24 |
25 | const baseList = ['gmail.com', 'yahoo.com', 'hotmail.com', 'aol.com', 'msn.com', 'proton.me']
26 |
27 | export function EventsChildren() {
28 | const inputRef = useRef(null)
29 | const [email, setEmail] = useState('sadjhgghjsadghjds')
30 | const [validity, setValidity] = useState(Valididty.IDLE)
31 |
32 | useLayoutEffect(() => {
33 | setValidity(getValidity(email))
34 | // eslint-disable-next-line react-hooks/exhaustive-deps
35 | }, [])
36 |
37 | function handleFocus() {
38 | setValidity(Valididty.IDLE)
39 | }
40 |
41 | function handleBlur() {
42 | if (email.length > 0) setValidity(getValidity(email))
43 | }
44 |
45 | function handleSelect({ value }: OnSelectData) {
46 | setValidity(getValidity(value))
47 | }
48 |
49 | function getValidity(value: string) {
50 | return testEmail(value) ? Valididty.VALID : Valididty.INVALID
51 | }
52 |
53 | const isValid = validity === Valididty.VALID
54 | const isInvalid = validity === Valididty.INVALID
55 |
56 | function getValidityClasses() {
57 | if (isValid) return 'validInput'
58 | if (isInvalid) return 'invalidInput'
59 | return ''
60 | }
61 |
62 | return (
63 |
64 |
65 |
82 | {isValid && }
83 | {isInvalid && }
84 |
85 |
86 | )
87 | }
88 |
89 | const ValidIcon = () => (
90 |
108 | )
109 |
110 | const invalidIconAttrs: SVGAttributes = {
111 | transform: 'translate(5.659 5.659)',
112 | fill: 'none',
113 | stroke: '#ff7272',
114 | strokeLinecap: 'round',
115 | strokeLinejoin: 'round',
116 | strokeWidth: '3',
117 | }
118 |
119 | const InvalidIcon = () => (
120 |
133 | )
134 |
--------------------------------------------------------------------------------
/tests/Email.cy.tsx:
--------------------------------------------------------------------------------
1 | import { Email } from './Email'
2 | import { getRandomIndex, getRandomInt } from '../cypress/support/component'
3 | import domains from '../src/domains.json'
4 |
5 | it('Should pass ARIA axe tests', () => {
6 | cy.log('Axe not supported on Firefox or Webkit')
7 |
8 | cy.mount()
9 | cy.withinRoot(() => {
10 | cy.get('input').type('myuser')
11 | })
12 |
13 | cy.downArrow(2)
14 | cy.injectAxe()
15 | cy.checkA11y('.Root', {
16 | runOnly: ['cat.aria'],
17 | })
18 | })
19 |
20 | it('Should display coherent baseList suggestions according to input change', () => {
21 | const baseList = ['gmail.com', 'yahoo.com', 'hotmail.com', 'aol.com']
22 | cy.mount()
23 |
24 | cy.withinRoot(() => {
25 | cy.get('input').type('myusern')
26 | cy.get('li').each((li, index) => {
27 | expect(li.text()).to.contain(`myusern@${baseList[index]}`)
28 | })
29 | })
30 | })
31 |
32 | it('Should display coherent refineList suggestions according to input change', () => {
33 | cy.mount()
34 |
35 | const user = 'myusername'
36 |
37 | cy.withinRoot(() => {
38 | cy.get('input').type(`${user}@g`)
39 | cy.get('li').each((li) => {
40 | expect(li.text()).to.contain(`${user}@g`)
41 | })
42 | cy.get('input').type('m')
43 | cy.get('li').each((li) => {
44 | expect(li.text()).to.contain(`${user}@gm`)
45 | })
46 | cy.get('input').type('x')
47 | cy.get('li').each((li) => {
48 | expect(li.text()).to.contain(`${user}@gmx`)
49 | })
50 | })
51 | })
52 |
53 | it('Should hide baseList suggestions once users press @', () => {
54 | const baseList = ['gmail.com', 'yahoo.com', 'hotmail.com', 'aol.com']
55 | cy.mount()
56 |
57 | cy.withinRoot(() => {
58 | cy.get('input').type('myusername')
59 | cy.get('li').should('have.length', baseList.length)
60 | cy.get('input').type('@')
61 | cy.get('ul').should('not.exist')
62 | })
63 | })
64 |
65 | it('Should hide suggestions if no match', () => {
66 | cy.mount()
67 |
68 | cy.withinRoot(() => {
69 | cy.get('input').type('myusername@g')
70 | cy.get('ul').should('exist')
71 | cy.get('input').type('xsdasdsad')
72 | cy.get('ul').should('not.exist')
73 | })
74 | })
75 |
76 | it('Should hide suggestions if clearing', () => {
77 | cy.mount()
78 |
79 | cy.withinRoot(() => {
80 | cy.get('input').type('myusername@g')
81 | cy.get('ul').should('exist')
82 | cy.get('input').clear()
83 | cy.get('ul').should('not.exist')
84 | })
85 | })
86 |
87 | it('Should hide suggestions if exact match', () => {
88 | cy.mount()
89 |
90 | cy.withinRoot(() => {
91 | cy.get('input').type('myusername@g')
92 | cy.get('li').should('exist')
93 | cy.get('input').clear().type('myusername@gmail.com')
94 | cy.get('ul').should('not.exist')
95 | cy.get('input').type('{backspace}')
96 | cy.get('ul').should('exist')
97 | })
98 | })
99 |
100 | it('Should hide suggestions if clicking outside', () => {
101 | cy.mount()
102 |
103 | cy.withinRoot(() => {
104 | cy.get('input').type('myusername@g')
105 | cy.get('ul').should('exist')
106 | })
107 |
108 | cy.get('body').trigger('click')
109 | cy.get('.dropdownClass').should('not.exist')
110 | })
111 |
112 | it('Should hide suggestions if pressing escape key', () => {
113 | cy.mount()
114 |
115 | cy.withinRoot(() => {
116 | cy.get('input').type('myusername@g')
117 | cy.get('ul').should('exist')
118 | })
119 |
120 | cy.realType('{esc}')
121 | cy.get('.dropdownClass').should('not.exist')
122 | })
123 |
124 | it('Should hide refineList suggestions if multiple @ in domain', () => {
125 | cy.mount()
126 |
127 | cy.withinRoot(() => {
128 | cy.get('input').type('myusername@gm@')
129 | cy.get('ul').should('not.exist')
130 | })
131 | })
132 |
133 | it('Should hide refineList suggestions if deleting username', () => {
134 | cy.mount()
135 |
136 | const username = 'username'
137 | const domain = '@gmail'
138 |
139 | cy.withinRoot(() => {
140 | cy.get('input').type(`${username}${domain}`)
141 | cy.get('ul').should('exist')
142 | cy.get('input').type('{leftArrow}'.repeat(domain.length)).type('{backspace}'.repeat(username.length))
143 | cy.get('ul').should('not.exist')
144 | })
145 | })
146 |
147 | it('Should update suggestions username on username change', () => {
148 | const initialUsername = 'myusername'
149 |
150 | it('Username', () => {
151 | cy.mount()
152 |
153 | cy.withinRoot(() => {
154 | cy.get('input').type(`${initialUsername}@g`)
155 | cy.get('span:first-of-type').each((span) => {
156 | expect(span.text()).to.be.eq(initialUsername)
157 | })
158 |
159 | cy.get('input').type(`{leftArrow}{leftArrow}`)
160 | const charsToDel = getRandomInt(1, initialUsername.length)
161 | cy.get('input').type(`${'{backspace}'.repeat(charsToDel)}`)
162 |
163 | cy.get('span:first-of-type').each((span) => {
164 | expect(span.text()).to.be.eq(initialUsername.slice(0, -charsToDel))
165 | })
166 | })
167 | })
168 | })
169 |
170 | it('Should update input value on suggestion click', () => {
171 | cy.mount()
172 |
173 | for (let i = 0; i < 10; i++) {
174 | cy.withinRoot(() => {
175 | cy.get('input').type('myusername@g')
176 | cy.get('li').then((list) => {
177 | const randomIndex = getRandomIndex(list.length)
178 | list.eq(randomIndex).trigger('click')
179 | cy.get('input').should('have.value', list[randomIndex].textContent).clear()
180 | })
181 | })
182 | }
183 | })
184 |
185 | it('Should update input value on suggestion keydown', () => {
186 | cy.mount()
187 |
188 | for (let i = 0; i < 10; i++) {
189 | cy.withinRoot(() => {
190 | cy.get('input').type('myusername@g')
191 | cy.get('li').then((list) => {
192 | const randomIndex = getRandomIndex(list.length)
193 | cy.downArrow(randomIndex + 1)
194 | cy.get('li').eq(randomIndex).should('have.focus').type('{enter}')
195 | cy.get('input').should('have.value', list[randomIndex].textContent).clear()
196 | })
197 | })
198 | }
199 | })
200 |
201 | it('Should keyboard-navigate trough suggestions and input', () => {
202 | cy.mount()
203 |
204 | cy.withinRoot(() => {
205 | cy.get('input').type('myusername@g')
206 | cy.get('li').then((list) => {
207 | const randomIndex = getRandomIndex(list.length)
208 | cy.downArrow(randomIndex + 1)
209 | cy.get('li').eq(randomIndex).should('have.focus')
210 | cy.upArrow(randomIndex + 1)
211 | cy.get('input').should('have.focus')
212 | })
213 | })
214 | })
215 |
216 | it('Should set previous focused suggestion by resuming from hovered one', () => {
217 | cy.mount()
218 |
219 | cy.withinRoot(() => {
220 | cy.get('input').type('myusername@g')
221 | cy.get('li').then((list) => {
222 | let randomIndex = getRandomIndex(list.length)
223 | // Force to not get the first suggestion index
224 | if (randomIndex === 0) {
225 | randomIndex = 1
226 | }
227 | cy.get('li').eq(randomIndex).realMouseMove(10, 10)
228 | cy.get('input').type('{upArrow}')
229 |
230 | cy.get('li')
231 | .eq(randomIndex - 1)
232 | .should('have.focus')
233 | })
234 | })
235 | })
236 |
237 | it('Should update focused suggestion by resuming from hovered one', () => {
238 | cy.mount()
239 |
240 | cy.withinRoot(() => {
241 | cy.get('input').type('myusername@g')
242 |
243 | cy.downArrow(1) // Focus 1st suggestion
244 | cy.get('li').eq(0).should('have.focus')
245 | cy.get('li').eq(3).realMouseMove(10, 10) // Hover 4th suggestion
246 | cy.upArrow(1)
247 | cy.get('li').eq(2).should('have.focus')
248 | })
249 | })
250 |
251 | it('Should focus first suggestion if pressing arrow down on last one', () => {
252 | cy.mount()
253 |
254 | cy.withinRoot(() => {
255 | cy.get('input').type('myusername@g')
256 | cy.downArrow(1)
257 | cy.get('li').then((list) => {
258 | cy.downArrow(list.length)
259 | cy.get('li').eq(0).should('have.focus')
260 | })
261 | })
262 | })
263 |
264 | it('Should focus and update input value if pressing alphanumeric chars from a suggestion', () => {
265 | cy.mount()
266 |
267 | cy.withinRoot(() => {
268 | cy.get('input').type('myusername@g')
269 | cy.get('li').then((list) => {
270 | cy.downArrow(getRandomIndex(list.length))
271 | cy.realType('mail')
272 | cy.get('input').should('have.focus').and('have.value', 'myusername@gmail')
273 | })
274 | })
275 | })
276 |
277 | it('Should focus and update input value if pressing @ from a suggestion', () => {
278 | cy.mount()
279 |
280 | cy.withinRoot(() => {
281 | cy.get('input').type('myusern')
282 | cy.get('li').then((list) => {
283 | cy.downArrow(getRandomIndex(list.length))
284 | cy.realType('@gm')
285 | cy.get('input').should('have.focus').and('have.value', 'myusern@gm')
286 | })
287 | })
288 | })
289 |
290 | it('Should focus and update input value if pressing . from a suggestion', () => {
291 | cy.mount()
292 |
293 | cy.withinRoot(() => {
294 | cy.get('input').type('myusern')
295 | cy.get('li').then((list) => {
296 | cy.downArrow(getRandomIndex(list.length))
297 | cy.realType('.')
298 | cy.get('input').should('have.focus').and('have.value', 'myusern.')
299 | })
300 | })
301 | })
302 |
303 | it('Should focus and update input value if pressing backspace on a suggestion', () => {
304 | cy.mount()
305 |
306 | const initialValue = 'myusername@g'
307 | const charsToDel = 2
308 |
309 | cy.withinRoot(() => {
310 | cy.get('input').type(initialValue)
311 | cy.get('li').then((list) => {
312 | cy.downArrow(list.length)
313 | cy.realType('{backspace}'.repeat(charsToDel))
314 | cy.get('input').should('have.focus').and('have.value', initialValue.slice(0, -charsToDel))
315 | })
316 | })
317 | })
318 |
319 | it('Should open dropdown only after minChars is reached', () => {
320 | cy.mount()
321 |
322 | const charsArr = 'myus'.split('')
323 |
324 | cy.withinRoot(() => {
325 | charsArr.forEach((char, index) => {
326 | cy.get('input').type(char)
327 | if (index === charsArr.length - 1) {
328 | cy.get('ul').should('exist')
329 | } else {
330 | cy.get('ul').should('not.exist')
331 | }
332 | })
333 | })
334 | })
335 |
336 | it('Should display maximum user-defined result number', () => {
337 | cy.mount()
338 |
339 | cy.withinRoot(() => {
340 | cy.get('input').type('myusername@g')
341 | cy.get('li').should('have.length', 3)
342 | })
343 | })
344 |
345 | it('Should trigger user onBlur/onFocus only if related target is not a suggestion', () => {
346 | cy.mount()
347 |
348 | cy.withinRoot(() => {
349 | cy.get('input').focus().type('myusername@g')
350 | cy.get('li').then((list) => {
351 | const randomIndex = getRandomIndex(list.length)
352 | for (let i = 0; i < 10; i++) {
353 | cy.downArrow(randomIndex + 1)
354 | cy.upArrow(randomIndex + 1)
355 | }
356 | cy.get('input').should('have.focus').blur()
357 | })
358 | })
359 |
360 | cy.get('#CyFocusData').should('have.attr', 'data-cy-focus', '1').and('have.attr', 'data-cy-blur', '1')
361 | })
362 |
363 | it('Should forward HTML attributes to input element', () => {
364 | const name = 'MyName'
365 | const placeholder = 'MyPlaceholder'
366 | const pattern = '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$'
367 |
368 | cy.mount()
369 |
370 | cy.withinRoot(() => {
371 | cy.get('input')
372 | .should('have.attr', 'name', name)
373 | .and('have.attr', 'placeholder', placeholder)
374 | .and('have.attr', 'pattern', pattern)
375 | .and('be.disabled')
376 | .and('have.attr', 'readonly', 'readonly')
377 | .and('have.attr', 'required', 'required')
378 | })
379 | })
380 |
381 | it('Should forward props to dropdown', () => {
382 | cy.mount()
383 | cy.get('input').type('myusername')
384 |
385 | cy.withinRoot(() => {
386 | cy.get('ul').should('have.attr', 'aria-label', 'foo')
387 | })
388 | })
389 |
390 | it('Should set custom active data attribute', () => {
391 | cy.mount()
392 | cy.get('input').type('myusername')
393 | cy.downArrow(1)
394 |
395 | cy.withinRoot(() => {
396 | cy.get('li').eq(0).should('have.attr', 'data-custom', 'true')
397 | })
398 | })
399 |
400 | describe('Classnames', () => {
401 | const classes = {
402 | wrapper: 'WC',
403 | input: 'IC',
404 | username: 'UC',
405 | domain: 'DC',
406 | }
407 |
408 | it('Should add a custom wrapper class', () => {
409 | cy.mount()
410 | cy.get('.customWrapperClass').should('exist')
411 | })
412 |
413 | it('Should add custom classes', () => {
414 | cy.mount(
415 |
422 | )
423 |
424 | cy.withinRoot(() => {
425 | cy.get('input').should('have.class', 'IC').type('myusername')
426 | cy.get('ul').should('have.class', 'DPC')
427 | cy.get('li').should('have.class', 'SC')
428 | cy.get('span:first-of-type').should('have.class', 'UC')
429 | cy.get('span:last-of-type').should('have.class', 'DC')
430 | })
431 | })
432 |
433 | it('Should add only defined classes', () => {
434 | cy.mount()
435 |
436 | cy.withinRoot(() => {
437 | cy.get('input').should('have.class', 'IC').type('myusername')
438 | cy.get('ul').should('not.have.class', 'DPC')
439 | cy.get('li').should('not.have.class', 'SC')
440 | cy.get('span:first-of-type').should('have.class', 'UC')
441 | cy.get('span:last-of-type').should('have.class', 'DC')
442 | })
443 | })
444 |
445 | it('Should add both wrapper classes', () => {
446 | cy.mount()
447 | cy.get('.wrapperClass').should('have.class', 'WC')
448 | })
449 | })
450 |
--------------------------------------------------------------------------------
/src/Email.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef, useCallback, useEffect, useId, useRef, useState } from 'react'
2 | import { flushSync } from 'react-dom'
3 | import { cleanValue, getHonestValue, isFn, alphanumericKeys, getEmailData } from './utils'
4 | import { Events, OnSelectData, Elements, Maybe, EmailProps } from './types'
5 |
6 | /**
7 | * Controlled email input component.
8 | *
9 | * Read the documentation at: https://github.com/smastrom/react-email-autocomplete.
10 | */
11 | export const Email = forwardRef(
12 | (
13 | {
14 | /* Core - Required */
15 | onChange: setEmail,
16 | value: _email,
17 | baseList: _baseList,
18 | /* Core - Optional */
19 | refineList = [],
20 | maxResults: _maxResults = 6,
21 | minChars: _minChars = 2,
22 | className,
23 | classNames,
24 | onSelect = () => {},
25 | children,
26 | activeDataAttr,
27 | /* User events */
28 | onFocus: userOnFocus,
29 | onBlur: userOnBlur,
30 | onInput: userOnInput,
31 | onKeyDown: userOnKeyDown = () => {},
32 | /* ARIA */
33 | dropdownAriaLabel = 'Suggestions',
34 | /* HTML */
35 | ...inputAttrs
36 | }: EmailProps,
37 | externalRef
38 | ) => {
39 | /* User settings */
40 |
41 | const isRefineMode = refineList?.length > 0
42 | const maxResults = getHonestValue(_maxResults, 8, 6)
43 | const minChars = getHonestValue(_minChars, 8, 2)
44 | const baseList = _baseList.slice(0, maxResults)
45 |
46 | /* Refs */
47 |
48 | const isTouched = useRef(false)
49 |
50 | const listId = useId()
51 |
52 | const wrapperRef = useRef>(null)
53 | const inputRef = useRef>(null)
54 | const dropdownRef = useRef>(null)
55 | const liRefs = useRef[] | []>([])
56 |
57 | /* State */
58 |
59 | const [inputType, setInputType] = useState<'text' | 'email'>('email')
60 | const [suggestions, setSuggestions] = useState(baseList)
61 |
62 | /**
63 | * 'focusedIndex' is used to trigger suggestions focus
64 | * and can only be set by keyboard events.
65 | *
66 | * 'hoveredIndex' is used to keep track of both focused/hovered
67 | * suggestion in order to set 'data-active-email="true"'.
68 | *
69 | * When focusedIndex is set, hoveredIndex is set to the same value.
70 | * When hoveredIndex is set by pointer events, focusedIndex is set to -1.
71 | *
72 | * Keyboard handlers are able to set the new focus by 'resuming' from
73 | * any eventual 'hoveredIndex' triggered by pointer events and viceversa.
74 | */
75 | const [activeSuggestion, _setActiveSuggestion] = useState({
76 | focusedIndex: -1,
77 | hoveredIndex: -1,
78 | })
79 |
80 | function setActiveSuggestion(focusedIndex: number, hoveredIndex: number) {
81 | _setActiveSuggestion({ focusedIndex, hoveredIndex })
82 | }
83 |
84 | /**
85 | * Resumes keyboard focus from an eventual hovered suggestion.
86 | */
87 | function setActiveSuggestionFromHover({ isDecrement }: { isDecrement: boolean }) {
88 | const i = isDecrement ? -1 : 1
89 |
90 | _setActiveSuggestion((prevState) => ({
91 | hoveredIndex: prevState.hoveredIndex + i,
92 | focusedIndex: prevState.hoveredIndex + i,
93 | }))
94 | }
95 |
96 | /* Reactive helpers */
97 |
98 | const email = typeof _email !== 'string' ? '' : cleanValue(_email)
99 | const [username] = email.split('@')
100 |
101 | /**
102 | * 'isOpen' conditionally renders the dropdown, we simply let the
103 | * results length decide if it should be mounted or not.
104 | */
105 | const isOpen = isTouched.current && suggestions.length > 0 && username.length >= minChars
106 |
107 | /* Callbacks */
108 |
109 | const clearResults = useCallback(() => {
110 | setSuggestions([])
111 | setActiveSuggestion(-1, -1)
112 | }, [])
113 |
114 | /* Effects */
115 |
116 | useEffect(() => {
117 | if (activeSuggestion.focusedIndex >= 0) {
118 | liRefs?.current[activeSuggestion.focusedIndex]?.focus()
119 | }
120 | }, [activeSuggestion.focusedIndex])
121 |
122 | useEffect(() => {
123 | function handleOutsideClick(e: MouseEvent) {
124 | if (isOpen && !wrapperRef.current?.contains(e.target as Node)) {
125 | clearResults()
126 | }
127 | }
128 |
129 | if (!isOpen) setActiveSuggestion(-1, -1)
130 |
131 | document.addEventListener('mousedown', handleOutsideClick)
132 | window.addEventListener('blur', clearResults)
133 |
134 | return () => {
135 | document.removeEventListener('mousedown', handleOutsideClick)
136 | window.removeEventListener('blur', clearResults)
137 | }
138 | }, [isOpen, clearResults])
139 |
140 | /* Event utils */
141 |
142 | function handleCursorFocus() {
143 | if (inputRef.current) {
144 | flushSync(() => {
145 | setInputType('text')
146 | })
147 |
148 | inputRef.current.setSelectionRange(email.length, email.length)
149 |
150 | flushSync(() => {
151 | setInputType('email')
152 | })
153 |
154 | inputRef.current.focus()
155 | }
156 | }
157 |
158 | /* Value handlers */
159 |
160 | function handleEmailChange(e: React.ChangeEvent) {
161 | /**
162 | * On first mount/change, suggestions state is init with baseList.
163 | * As soon as the username is longer than minChars, the dropdown
164 | * is immediately mounted as such domains should be displayed
165 | * without any further condition by both modes.
166 | *
167 | * We also want the dropdown to be mounted exclusively
168 | * when users type so we set this ref to true.
169 | */
170 | isTouched.current = true
171 |
172 | const cleanEmail = cleanValue(e.target.value)
173 | const { hasUsername, hasAt, hasDomain, domain: _domain } = getEmailData(cleanEmail, minChars)
174 |
175 | if (hasUsername) {
176 | if (!isRefineMode) {
177 | hasAt ? clearResults() : setSuggestions(baseList)
178 | } else {
179 | if (hasDomain) {
180 | const _suggestions = refineList
181 | .filter((_suggestion) => _suggestion.startsWith(_domain))
182 | .slice(0, maxResults)
183 | if (_suggestions.length > 0) {
184 | /**
185 | * We also want to close the dropdown if users type exactly
186 | * the same domain of the first/only suggestion.
187 | *
188 | * This will also unmount the dropdown after selecting a suggestion.
189 | */
190 | _suggestions[0] === _domain ? clearResults() : setSuggestions(_suggestions)
191 | } else {
192 | clearResults()
193 | }
194 | } else {
195 | setSuggestions(baseList)
196 | }
197 | }
198 | }
199 |
200 | setEmail(cleanEmail)
201 | }
202 |
203 | function dispatchSelect(
204 | value: OnSelectData['value'],
205 | keyboard: OnSelectData['keyboard'],
206 | position: OnSelectData['position']
207 | ) {
208 | onSelect({ value, keyboard, position })
209 | }
210 |
211 | function handleSelect(
212 | e: React.MouseEvent | React.KeyboardEvent,
213 | activeSuggestion: number,
214 | { isKeyboard, isInput }: { isKeyboard: boolean; isInput: boolean } = { isKeyboard: false, isInput: false }
215 | ) {
216 | e.preventDefault()
217 | e.stopPropagation()
218 |
219 | flushSync(() => {
220 | let selectedEmail = ''
221 |
222 | if (isInput) {
223 | selectedEmail = liRefs.current[activeSuggestion]?.textContent as string
224 | } else {
225 | selectedEmail = e.currentTarget.textContent as string
226 | }
227 |
228 | selectedEmail = cleanValue(selectedEmail)
229 |
230 | setEmail(selectedEmail)
231 | dispatchSelect(selectedEmail, isKeyboard, activeSuggestion + 1)
232 | clearResults()
233 | })
234 |
235 | inputRef.current?.focus()
236 | }
237 |
238 | /* Keyboard events */
239 |
240 | function handleInputKeyDown(e: React.KeyboardEvent) {
241 | switch (e.code) {
242 | case 'Tab':
243 | case 'Escape':
244 | e.stopPropagation()
245 | return clearResults()
246 |
247 | /**
248 | * The conditions inside the following clauses allow the user to 'resume' and set the new focus
249 | * from an eventual hovered item.
250 | *
251 | * Since the input is focused when first hovering suggestions,
252 | * we must handle here the beginning of 'resume-from-hovered' logic here.
253 | */
254 | case 'ArrowUp':
255 | e.preventDefault()
256 | e.stopPropagation()
257 |
258 | if (activeSuggestion.hoveredIndex >= 0) setActiveSuggestionFromHover({ isDecrement: true })
259 |
260 | break
261 |
262 | case 'ArrowDown':
263 | e.preventDefault()
264 | e.stopPropagation()
265 |
266 | if (activeSuggestion.hoveredIndex >= 0) setActiveSuggestionFromHover({ isDecrement: false })
267 | if (activeSuggestion.hoveredIndex < 0) setActiveSuggestion(0, 0)
268 |
269 | break
270 |
271 | case 'Enter':
272 | e.preventDefault()
273 | e.stopPropagation()
274 |
275 | if (activeSuggestion.hoveredIndex >= 0)
276 | handleSelect(e, activeSuggestion.hoveredIndex, {
277 | isKeyboard: true,
278 | isInput: true,
279 | })
280 |
281 | break
282 | }
283 | }
284 |
285 | function handleListKeyDown(e: React.KeyboardEvent) {
286 | if (alphanumericKeys.test(e.key)) {
287 | e.stopPropagation()
288 | return inputRef?.current?.focus()
289 | }
290 |
291 | switch (e.code) {
292 | case 'Tab':
293 | e.stopPropagation()
294 | return clearResults()
295 |
296 | case 'Escape':
297 | e.preventDefault()
298 | e.stopPropagation()
299 |
300 | clearResults()
301 | return handleCursorFocus()
302 |
303 | case 'Enter':
304 | case 'Space':
305 | e.preventDefault()
306 | e.stopPropagation()
307 |
308 | return handleSelect(e, activeSuggestion.focusedIndex, {
309 | isKeyboard: true,
310 | isInput: false,
311 | })
312 |
313 | case 'Backspace':
314 | case 'ArrowLeft':
315 | case 'ArrowRight':
316 | e.stopPropagation()
317 | return inputRef?.current?.focus()
318 |
319 | /**
320 | * Same for the input handler, the conditions inside the
321 | * clauses allow users to set the new focus from
322 | * an eventual hovered item.
323 | *
324 | * Since we know that hoveredIndex is always set along with
325 | * focusedIndex, we are sure that the condition executes
326 | * also when no item was hovered using the pointer.
327 | */
328 | case 'ArrowUp':
329 | e.preventDefault()
330 | e.stopPropagation()
331 |
332 | setActiveSuggestionFromHover({ isDecrement: true })
333 |
334 | if (activeSuggestion.hoveredIndex === 0) inputRef?.current?.focus()
335 |
336 | break
337 |
338 | case 'ArrowDown':
339 | e.preventDefault()
340 | e.stopPropagation()
341 |
342 | if (activeSuggestion.hoveredIndex < suggestions.length - 1)
343 | setActiveSuggestionFromHover({ isDecrement: false })
344 | if (activeSuggestion.hoveredIndex === suggestions.length - 1) setActiveSuggestion(0, 0)
345 |
346 | break
347 | }
348 | }
349 |
350 | /* User Events */
351 |
352 | /**
353 | * User's focus/blur should be triggered only when the related
354 | * target is not a suggestion, this will ensure proper behavior
355 | * with external input validation.
356 | */
357 | function handleExternal(
358 | e: React.FocusEvent,
359 | eventHandler: React.FocusEventHandler
360 | ) {
361 | const isInternal = liRefs.current.some((li) => li === e.relatedTarget)
362 | if (!isInternal || e.relatedTarget == null) eventHandler(e)
363 | }
364 |
365 | function getEvents(): Events {
366 | return {
367 | onKeyDown(e) {
368 | handleInputKeyDown(e)
369 | userOnKeyDown(e)
370 | },
371 | ...(isFn(userOnInput) ? { onInput: userOnInput } : {}),
372 | ...(isFn(userOnBlur) ? { onBlur: (e) => handleExternal(e, userOnBlur!) } : {}),
373 | ...(isFn(userOnFocus) ? { onFocus: (e) => handleExternal(e, userOnFocus!) } : {}),
374 | }
375 | }
376 |
377 | /* Props */
378 |
379 | function mergeRefs(inputElement: HTMLInputElement) {
380 | inputRef.current = inputElement
381 | if (externalRef) {
382 | // eslint-disable-next-line no-extra-semi
383 | ;(externalRef as React.MutableRefObject>).current = inputElement
384 | }
385 | }
386 |
387 | function getWrapperClass() {
388 | return { className: `${className || ''} ${classNames?.wrapper || ''}`.trim() || undefined }
389 | }
390 |
391 | function getClasses(elementName: Elements) {
392 | if (classNames?.[elementName]) {
393 | return { className: classNames[elementName] }
394 | }
395 | return {}
396 | }
397 |
398 | return (
399 |
400 |
mergeRefs(input as HTMLInputElement)}
403 | onChange={(e) => handleEmailChange(e)}
404 | aria-expanded={isOpen}
405 | value={email}
406 | type={inputType}
407 | role={suggestions.length > 0 ? 'combobox' : ''}
408 | autoComplete="off"
409 | aria-autocomplete="list"
410 | {...(isOpen ? { 'aria-controls': listId } : {})}
411 | {...getClasses(Elements.Input)}
412 | {...getEvents()}
413 | />
414 | {isOpen && (
415 |
422 | {suggestions.map((domain, i) => (
423 | - (liRefs.current[i] = li)}
426 | onPointerMove={() => setActiveSuggestion(-1, i)}
427 | onPointerLeave={() => setActiveSuggestion(-1, -1)}
428 | onClick={(e) => handleSelect(e, i, { isKeyboard: false, isInput: false })}
429 | onKeyDown={handleListKeyDown}
430 | key={domain}
431 | aria-posinset={i + 1}
432 | aria-setsize={suggestions.length}
433 | tabIndex={-1}
434 | // This must always be false as no option can be already selected
435 | aria-selected="false"
436 | {...getClasses(Elements.Suggestion)}
437 | {...{
438 | [activeDataAttr ? activeDataAttr : 'data-active-email']: i === activeSuggestion.hoveredIndex,
439 | }}
440 | >
441 | {username}
442 | @{domain}
443 |
444 | ))}
445 |
446 | )}
447 | {children}
448 |
449 | )
450 | }
451 | )
452 |
453 | Email.displayName = 'Email'
454 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Email Autocomplete
2 |
3 |  
4 | 
5 |
6 | | Before typing `@` | After typing `@` (optional) |
7 | | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
8 | |  |  |
9 |
10 |
11 |
12 | **React Email Autocomplete** is an unstyled, zero-dependency component inspired by some european flight booking websites. As soon as users start typing their email address, it will suggest the most common email providers.
13 |
14 | - Completely unstyled and white labeled (ships with zero CSS)
15 | - Fully accessible with superlative keyboard controls
16 | - Forward any event and attribute to the `` element or control it with React Hook Form
17 |
18 | [Demo and examples](https://@smastrom/react-email-autocomplete.netlify.app) — [Stackblitz](https://stackblitz.com/edit/react-4kufqv?file=src/App.js) — [NextJS](https://stackblitz.com/edit/stackblitz-starters-f36nmm?file=app%2Fpage.tsx)
19 |
20 |
21 |
22 | ## :floppy_disk: Installation
23 |
24 | ```bash
25 | pnpm add @smastrom/react-email-autocomplete
26 | # npm i @smastrom/react-email-autocomplete
27 | # yarn add @smastrom/react-email-autocomplete
28 | ```
29 |
30 |
31 |
32 | ## :art: Usage / Styling
33 |
34 | The component renders a single `div` with a very simple structure:
35 |
36 | ```js
37 | Wrapper — div
38 | ├── Email Input Field — input
39 | └── Dropdown — ul
40 | └── Suggestions - li[]
41 | └──[username - span:first-of-type] [@domain.com - span:last-of-type]
42 | ```
43 |
44 | Specify `classNames` for each element you'd like to style:
45 |
46 | ```jsx
47 | import { Email } from '@smastrom/react-email-autocomplete'
48 |
49 | const classNames = {
50 | wrapper: 'my-wrapper',
51 | input: 'my-input',
52 | dropdown: 'my-dropdown',
53 | suggestion: 'my-suggestion',
54 | username: 'my-username',
55 | domain: 'my-domain',
56 | }
57 |
58 | const baseList = ['gmail.com', 'yahoo.com', 'hotmail.com', 'aol.com', 'msn.com']
59 |
60 | function App() {
61 | const [email, setEmail] = useState('')
62 |
63 | return (
64 | customSetter(newValue)
68 | value={email}
69 | />
70 | )
71 | }
72 | ```
73 |
74 | NextJS App Router
75 |
76 |
77 | `components/Email.tsx`
78 |
79 | ```tsx
80 | 'use client'
81 |
82 | import { useState } from 'react'
83 | import { Email as EmailAutocomplete } from '@smastrom/react-email-autocomplete'
84 |
85 | const classNames = {
86 | wrapper: 'my-wrapper',
87 | input: 'my-input',
88 | dropdown: 'my-dropdown',
89 | suggestion: 'my-suggestion',
90 | username: 'my-username',
91 | domain: 'my-domain',
92 | }
93 |
94 | const baseList = ['gmail.com', 'yahoo.com', 'hotmail.com', 'aol.com', 'msn.com']
95 |
96 | export function Email() {
97 | const [email, setEmail] = useState('')
98 |
99 | return (
100 | customSetter(newValue)
104 | value={email}
105 | />
106 | )
107 | }
108 | ```
109 |
110 | `app/page.tsx`
111 |
112 | ```tsx
113 | import { Email } from '@/components/Email'
114 |
115 | export default function Home() {
116 | return (
117 |
118 | {/* ... */}
119 |
120 | {/* ... */}
121 |
122 | )
123 | }
124 | ```
125 |
126 |
127 |
128 | TypeScript
129 |
130 |
131 | ```ts
132 | import type { ClassNames } from '@smastrom/react-email-autocomplete'
133 |
134 | const myClassNames: ClassNames = {
135 | wrapper: 'my-wrapper',
136 | input: 'my-input',
137 | }
138 | ```
139 |
140 |
141 |
142 | Tailwind Intellisense
143 |
144 |
145 | You can add a this property in VSCode's `settings.json` in order to enable autcomplete for any object property or variable ending with `ClassNames`.
146 |
147 | ```json
148 | "tailwindCSS.experimental.classRegex": [
149 | ["ClassNames \\=([^;]*);", "'([^']*)'"],
150 | ["ClassNames \\=([^;]*);", "\"([^\"]*)\""],
151 | ["ClassNames \\=([^;]*);", "\\`([^\\`]*)\\`"]
152 | ],
153 | ```
154 |
155 |
156 |
157 | Basic styles
158 |
159 |
160 |
161 | This package ships with **zero css**. Initial styles enough to see the component in action may match the following properties:
162 |
163 | ```css
164 | .my-wrapper,
165 | .my-input {
166 | position: relative;
167 | }
168 |
169 | .my-input,
170 | .my-dropdown,
171 | .my-suggestion {
172 | font-size: inherit;
173 | box-sizing: border-box;
174 | width: 100%;
175 | }
176 |
177 | .my-dropdown {
178 | position: absolute;
179 | margin: 0.45rem 0 0 0;
180 | padding: 0;
181 | list-style-type: none;
182 | z-index: 999;
183 | }
184 |
185 | .my-suggestion {
186 | cursor: pointer;
187 | user-select: none;
188 | overflow: hidden;
189 | }
190 | ```
191 |
192 |
193 |
194 | ### Focus/Hover styles
195 |
196 | Although you can target the pseudo classes `:hover` and `:focus`, it is recommended instead to target the attribute `data-active-email` in order to avoid `:hover` styles to be applied to a suggestion as soon as the dropdown is opened (in case the cursor is hovering it).
197 |
198 | ```css
199 | .my-suggestion[data-active-email='true'] {
200 | background-color: aliceblue;
201 | }
202 |
203 | .my-suggestion:hover,
204 | .my-suggestion:focus,
205 | .my-suggestion:focus-visible {
206 | outline: none;
207 | }
208 | ```
209 |
210 | The attribute name can also be customized via `activeDataAttr` prop:
211 |
212 | ```jsx
213 |
222 | ```
223 |
224 | ```css
225 | .my-suggestion[data-custom-attr='true'] {
226 | background-color: aliceblue;
227 | }
228 | ```
229 |
230 | ## :dna: Modes
231 |
232 | ### 1. Basic Mode
233 |
234 | Once users start typing, it displays a list of _base_ suggestions and hides it once they type `@` . It already gives a nice UX and should be enough for the vast majority of websites:
235 |
236 | | Before typing `@` | After typing `@` |
237 | | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
238 | |  |  |
239 |
240 | ```jsx
241 | import { Email } from '@smastrom/react-email-autocomplete'
242 |
243 | const baseList = [
244 | 'gmail.com',
245 | 'yahoo.com',
246 | 'hotmail.com',
247 | 'aol.com',
248 | 'msn.com',
249 | 'proton.me',
250 | ]
251 |
252 | function App() {
253 | const [email, setEmail] = useState('')
254 |
255 | return (
256 | customSetter(newValue)
259 | value={email}
260 | />
261 | )
262 | }
263 | ```
264 |
265 | ### 2. Refine Mode (optional)
266 |
267 | Acts like **Basic Mode** until users type `@` . Then as they start typing the domain, it starts refining suggestions according to an extended list of domains.
268 |
269 | | Before typing `@` | After typing `@` |
270 | | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
271 | |  |  |
272 |
273 | All you have to do is to provide a second array of domains to `refineList` prop. This package ships with a [curated list](https://github.com/smastrom/@smastrom/react-email-autocomplete/blob/main/src/domains.json) of the ~160 most popular world domains that you can directly import and use (thanks to **@mailcheck**):
274 |
275 | ```jsx
276 | import { Email, domains } from '@smastrom/react-email-autocomplete'
277 |
278 | const baseList = [
279 | 'gmail.com',
280 | 'yahoo.com',
281 | 'hotmail.com',
282 | 'aol.com',
283 | 'msn.com',
284 | 'proton.me',
285 | ]
286 |
287 | function App() {
288 | const [email, setEmail] = useState('')
289 |
290 | return (
291 | customSetter(newValue)
295 | value={email}
296 | />
297 | )
298 | }
299 | ```
300 |
301 | Alternatively, you can use your own array of domains or [search]() for the one that best suits your audience.
302 |
303 |
304 |
305 | ## :globe_with_meridians: Localization
306 |
307 | This package ships with an optional hook that simplifies managing different lists of domains according to the [browser's locale](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/language).
308 |
309 | **1 - Create an object and define lists for each browser locale:**
310 |
311 | ```js
312 | export const emailProviders = {
313 | default: [
314 | 'gmail.com',
315 | 'yahoo.com',
316 | 'hotmail.com',
317 | 'aol.com',
318 | // ...
319 | ],
320 | it: [
321 | 'gmail.com',
322 | 'yahoo.com',
323 | 'yahoo.it',
324 | 'tiscali.it',
325 | // ...
326 | ],
327 | 'it-CH': [
328 | 'gmail.com',
329 | 'outlook.com',
330 | 'bluewin.ch',
331 | 'gmx.de',
332 | // ...
333 | ],
334 | }
335 | ```
336 |
337 | TypeScript
338 |
339 |
340 | ```ts
341 | import type { LocalizedList } from '@smastrom/react-email-autocomplete'
342 |
343 | export const emailProviders: LocalizedList = {
344 | default: [
345 | 'gmail.com',
346 | 'yahoo.com',
347 | 'hotmail.com',
348 | 'aol.com',
349 | // ...
350 | ],
351 | // ...
352 | }
353 | ```
354 |
355 |
356 |
357 | You may define [lang codes](https://www.localeplanet.com/icu/iso639.html) with or without country codes.
358 |
359 | For languages without country code (such as `it`), by default it will match all browser locales beginning with it such as `it`, `it-CH`, `it-IT` and so on.
360 |
361 | For languages with country code (`it-CH`) it will match `it-CH` but not `it` or `it-IT`.
362 |
363 | If you define both `it-CH` and `it`, `it-CH` will match only `it-CH` and `it` will match `it`, `it-IT` and so on.
364 |
365 | **2 - Use the hook:**
366 |
367 | ```jsx
368 | import { Email, useLocalizedList } from '@smastrom/react-email-autocomplete'
369 | import { emailProviders } from '@/src/static/locales'
370 |
371 | function App() {
372 | const baseList = useLocalizedList(emailProviders)
373 | const [email, setEmail] = useState('')
374 |
375 | return (
376 | customSetter(newValue)
379 | value={email}
380 | />
381 | )
382 | }
383 | ```
384 |
385 | ### Usage with internationalization frameworks or SSR
386 |
387 | To manually set the locale, pass its code as second argument:
388 |
389 | ```jsx
390 | import { useRouter } from 'next/router'
391 | import { emailProviders } from '@/src/static/locales'
392 | import { Email, useLocalizedList } from '@smastrom/react-email-autocomplete'
393 |
394 | function App() {
395 | const { locale } = useRouter()
396 | const baseList = useLocalizedList(emailProviders, locale)
397 |
398 | const [email, setEmail] = useState('')
399 |
400 | return (
401 | customSetter(newValue)
404 | value={email}
405 | />
406 | )
407 | }
408 | ```
409 |
410 | Or with NextJS App router:
411 |
412 | `components/Email.tsx`
413 |
414 | ```tsx
415 | 'use client'
416 |
417 | import {
418 | Email as EmailAutocomplete,
419 | useLocalizedList,
420 | } from '@smastrom/react-email-autocomplete'
421 | import { emailProviders } from '@/static/locales'
422 |
423 | export function Email({ lang }: { lang: string }) {
424 | const baseList = useLocalizedList(emailProviders, lang)
425 | const [email, setEmail] = useState('')
426 |
427 | return (
428 |
434 | )
435 | }
436 | ```
437 |
438 | `app/page.tsx`
439 |
440 | ```tsx
441 | import { Email } from '@/components/Email'
442 | import { headers } from 'next/headers'
443 |
444 | export default function Home() {
445 | const headersList = headers()
446 | const lang = headersList.get('accept-language')?.split(',')[0]
447 |
448 | return (
449 |
450 |
451 |
452 | )
453 | }
454 | ```
455 |
456 |
457 |
458 | ## :8ball: onSelect callback
459 |
460 | To invoke a callback everytime a suggestion is selected (either with mouse or keyboard), pass a callback to `onSelect` prop:
461 |
462 | ```jsx
463 | import { Email } from '@smastrom/react-email-autocomplete'
464 |
465 | function handleSelect(data) {
466 | console.log(data) // { value: 'johndoe@gmail.com', keyboard: true, position: 0 }
467 | }
468 |
469 | function App() {
470 | const [email, setEmail] = useState('')
471 |
472 | return (
473 | customSetter(newValue)
476 | onSelect={handleSelect}
477 | value={email}
478 | />
479 | )
480 | }
481 | ```
482 |
483 | Type Definition
484 |
485 |
486 | ```ts
487 | type OnSelectData = {
488 | value: string
489 | keyboard: boolean
490 | position: number
491 | }
492 |
493 | type OnSelect = (object: OnSelectData) => void | Promise
494 | ```
495 |
496 |
497 |
498 |
499 |
500 | ## :cyclone: Props
501 |
502 | | Prop | Description | Type | Default | Required |
503 | | ------------------- | ----------------------------------------------------- | -------------------------------------- | ------------------- | ------------------ |
504 | | `value` | State or portion of state that holds the email value | _string_ | undefined | :white_check_mark: |
505 | | `onChange` | State setter or custom dispatcher to update the email | _OnChange_ | undefined | :white_check_mark: |
506 | | `baseList` | Domains to suggest while typing the username | _string[]_ | undefined | :white_check_mark: |
507 | | `refineList` | Domains to refine suggestions after typing `@` | _string[]_ | [] | :x: |
508 | | `onSelect` | Custom callback on suggestion select | _OnSelect_ | () => {} | :x: |
509 | | `minChars` | Minimum chars required to display suggestions | _1 \| 2 \| 3 \| 4 \| 5 \| 6 \| 7 \| 8_ | 2 | :x: |
510 | | `maxResults` | Maximum number of suggestions to display | _2 \| 3 \| 4 \| 5 \| 6 \| 7 \| 8_ | 6 | :x: |
511 | | `classNames` | Class names for each element | _ClassNames_ | undefined | :x: |
512 | | `className` | Class name of the root element | _string_ | undefined | :x: |
513 | | `activeDataAttr` | Attribute name to set on focused/hovered suggestion | _string_ | `data-active-email` | :x: |
514 | | `dropdownAriaLabel` | Aria label for the dropdown list | _string_ | `Suggestions` | :x: |
515 |
516 | :bulb: React's `ref` and any other `HTMLInputElement` attribute can be passed as prop to the component and it will be forwarded to the input element.
517 |
518 |
519 |
520 | ## :keyboard: Keyboard controls
521 |
522 | - **↑ ↓** - Navigate through suggestions / input
523 | - **← →** - Move cursor and focus the input field while keeping list open
524 | - **Backspace / Alphanumeric keys** - Edit the input value and keep refining suggestions
525 | - **Enter / Space** - Confirm the suggestion
526 | - **Escape** - Close the list and focus the input field
527 | - **Tab / Shift + Tab** - Close the list and go to next/prev focusable input
528 |
529 |
530 |
531 | ## React Hook Form
532 |
533 | No special configuration needed, it just works. Just follow the official React Hook Form's [Controller documentation](https://react-hook-form.com/api/usecontroller/controller).
534 |
535 |
536 |
537 | ## :dvd: License
538 |
539 | MIT
540 |
--------------------------------------------------------------------------------