Skip to content

Commit

Permalink
1.2.0 (#3)
Browse files Browse the repository at this point in the history
* fix flaky tests, remove deprecated features tests

* deprecate `wrapperId`, `customPrefix`, `isInvalid`

* add dyanmic input type switch, set `aria-selected` to always false

* add close dropdown on right click and window blur

* allow `@` and `.` to keep typing email on suggestion focused

* edit examples

* edit readme

* add new tests

* bump v1.2.0
  • Loading branch information
smastrom authored Dec 10, 2023
1 parent 153410c commit 2f4b31a
Show file tree
Hide file tree
Showing 12 changed files with 176 additions and 136 deletions.
31 changes: 16 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ Although you can target the pseudo classes `:hover` and `:focus`, it is recommen
background-color: aliceblue;
}

.my-suggestion:focus {
.my-suggestion:hover,
.my-suggestion:focus,
.my-suggestion:focus-visible {
outline: none;
}
```
Expand Down Expand Up @@ -497,20 +499,19 @@ type OnSelect = (object: OnSelectData) => void | Promise<void>
## :cyclone: Props
| Prop | Description | Type | Default | Required |
| -------------- | ----------------------------------------------------- | -------------------------------------- | --------- | ------------------ |
| `value` | State or portion of state that holds the email value | _string_ | undefined | :white_check_mark: |
| `onChange` | State setter or custom dispatcher to update the email | _OnChange_ | undefined | :white_check_mark: |
| `baseList` | Domains to suggest while typing the username | _string[]_ | undefined | :white_check_mark: |
| `refineList` | Domains to refine suggestions after typing `@` | _string[]_ | [] | :x: |
| `onSelect` | Custom callback on suggestion select | _OnSelect_ | () => {} | :x: |
| `minChars` | Minimum chars required to display suggestions | _1 \| 2 \| 3 \| 4 \| 5 \| 6 \| 7 \| 8_ | 2 | :x: |
| `maxResults` | Maximum number of suggestions to display | _2 \| 3 \| 4 \| 5 \| 6 \| 7 \| 8_ | 6 | :x: |
| `classNames` | Class names for each element | _ClassNames_ | undefined | :x: |
| `className` | Class name of the wrapper element | _string_ | undefined | :x: |
| `wrapperId` | DOM ID of the wrapper element | _string_ | undefined | :x: |
| `customPrefix` | Custom prefix for dropdown unique ID | _string_ | `rbe_` | :x: |
| `isInvalid` | Value of `aria-invalid` | _boolean_ | undefined | :x: |
| Prop | Description | Type | Default | Required |
| ------------------- | ----------------------------------------------------- | -------------------------------------- | ------------------- | ------------------ |
| `value` | State or portion of state that holds the email value | _string_ | undefined | :white_check_mark: |
| `onChange` | State setter or custom dispatcher to update the email | _OnChange_ | undefined | :white_check_mark: |
| `baseList` | Domains to suggest while typing the username | _string[]_ | undefined | :white_check_mark: |
| `refineList` | Domains to refine suggestions after typing `@` | _string[]_ | [] | :x: |
| `onSelect` | Custom callback on suggestion select | _OnSelect_ | () => {} | :x: |
| `minChars` | Minimum chars required to display suggestions | _1 \| 2 \| 3 \| 4 \| 5 \| 6 \| 7 \| 8_ | 2 | :x: |
| `maxResults` | Maximum number of suggestions to display | _2 \| 3 \| 4 \| 5 \| 6 \| 7 \| 8_ | 6 | :x: |
| `classNames` | Class names for each element | _ClassNames_ | undefined | :x: |
| `className` | Class name of the root element | _string_ | undefined | :x: |
| `activeDataAttr` | Attribute name to set on focused/hovered suggestion | _string_ | `data-active-email` | :x: |
| `dropdownAriaLabel` | Aria label for the dropdown list | _string_ | `Suggestions` | :x: |
: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.
Expand Down
4 changes: 3 additions & 1 deletion app/Examples/BasicMode/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@
cursor: pointer;
}

.basicSuggestion:focus {
.basicSuggestion:hover,
.basicSuggestion:focus,
.basicSuggestion:focus-visible {
outline: none;
}

Expand Down
13 changes: 4 additions & 9 deletions app/Examples/EventsChildren/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ export function EventsChildren() {
}

function handleBlur() {
if (email.length > 0) {
setValidity(getValidity(email))
}
if (email.length > 0) setValidity(getValidity(email))
}

function handleSelect({ value }: OnSelectData) {
Expand All @@ -56,11 +54,8 @@ export function EventsChildren() {
const isInvalid = validity === Valididty.INVALID

function getValidityClasses() {
if (isValid) {
return 'validInput'
} else if (isInvalid) {
return 'invalidInput'
}
if (isValid) return 'validInput'
if (isInvalid) return 'invalidInput'
return ''
}

Expand All @@ -80,7 +75,7 @@ export function EventsChildren() {
onSelect={handleSelect}
baseList={baseList}
refineList={domains}
isInvalid={isInvalid}
aria-invalid={isInvalid}
value={email}
onChange={setEmail}
>
Expand Down
3 changes: 2 additions & 1 deletion app/Examples/EventsChildren/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@
}

.eventsSuggestion:hover,
.eventsSuggestion:focus {
.eventsSuggestion:focus,
.eventsSuggestion:focus-visible {
outline: none;
}

Expand Down
23 changes: 10 additions & 13 deletions app/Examples/RefineMode/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,16 @@ export function RefineMode() {
return (
<Section name="Refine Mode" folderName="RefineMode" className="refineSection">
<label htmlFor="refineMode">Email</label>
{/* <div style={{ height: 600, overflow: 'auto', width: '100%' }}> */}
<div /* style={{ height: 1200, display: 'flex', alignItems: 'center' }} */>
<Email
id="refineMode"
placeholder="Enter your email"
classNames={classes}
baseList={baseList}
refineList={domains}
value={email}
onChange={setEmail}
/>
</div>
{/* </div> */}

<Email
id="refineMode"
placeholder="Enter your email"
classNames={classes}
baseList={baseList}
refineList={domains}
value={email}
onChange={setEmail}
/>
</Section>
)
}
6 changes: 6 additions & 0 deletions app/Examples/RefineMode/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@
outline: none;
}

.refineSuggestion:hover,
.refineSuggestion:focus,
.refineSuggestion:focus-visible {
outline: none;
}

.refineUsername {
font-weight: 400;
padding-right: 0.1em;
Expand Down
53 changes: 39 additions & 14 deletions app/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@smastrom/react-email-autocomplete",
"version": "1.0.0",
"version": "1.2.0",
"private": false,
"homepage": "https://react-email-autocomplete.netlify.app",
"bugs": {
Expand Down
40 changes: 23 additions & 17 deletions src/Email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,15 @@ export const Email = forwardRef<HTMLInputElement, EmailProps>(
className,
classNames,
onSelect = () => {},
customPrefix = 'rbe_',
children,
wrapperId,
activeDataAttr,
/* User events */
onFocus: userOnFocus,
onBlur: userOnBlur,
onInput: userOnInput,
onKeyDown: userOnKeyDown = () => {},
/* ARIA */
isInvalid,
dropdownAriaLabel = 'List',
dropdownAriaLabel = 'Suggestions',
/* HTML */
...inputAttrs
}: EmailProps,
Expand All @@ -50,8 +47,7 @@ export const Email = forwardRef<HTMLInputElement, EmailProps>(

const isTouched = useRef(false)

const uniqueId = useId()
const listId = `${customPrefix}${uniqueId}`
const listId = useId()

const wrapperRef = useRef<Maybe<HTMLDivElement>>(null)
const inputRef = useRef<Maybe<HTMLInputElement>>(null)
Expand All @@ -60,11 +56,12 @@ export const Email = forwardRef<HTMLInputElement, EmailProps>(

/* State */

const [inputType, setInputType] = useState<'text' | 'email'>('email')
const [suggestions, setSuggestions] = useState(baseList)

/**
* 'focusedIndex' is used to trigger suggestions focus and set
* 'aria-selected' to 'true', it can only be set by keyboard events.
* 'focusedIndex' is used to trigger suggestions focus
* and can only be set by keyboard events.
*
* 'hoveredIndex' is used to keep track of both focused/hovered
* suggestion in order to set 'data-active-email="true"'.
Expand Down Expand Up @@ -131,18 +128,29 @@ export const Email = forwardRef<HTMLInputElement, EmailProps>(

if (!isOpen) setActiveSuggestion(-1, -1)

document.addEventListener('click', handleOutsideClick)
document.addEventListener('mousedown', handleOutsideClick)
window.addEventListener('blur', clearResults)

return () => {
document.removeEventListener('click', handleOutsideClick)
document.removeEventListener('mousedown', handleOutsideClick)
window.removeEventListener('blur', clearResults)
}
}, [isOpen, clearResults])

/* Event utils */

function handleCursorFocus() {
if (inputRef.current) {
flushSync(() => {
setInputType('text')
})

inputRef.current.setSelectionRange(email.length, email.length)

flushSync(() => {
setInputType('email')
})

inputRef.current.focus()
}
}
Expand Down Expand Up @@ -388,18 +396,17 @@ export const Email = forwardRef<HTMLInputElement, EmailProps>(
}

return (
<div ref={wrapperRef} id={wrapperId} {...getWrapperClass()}>
<div ref={wrapperRef} {...getWrapperClass()}>
<input
{...inputAttrs}
ref={(input) => mergeRefs(input as HTMLInputElement)}
onChange={(e) => handleEmailChange(e)}
aria-expanded={isOpen}
value={email}
type="text"
role="combobox"
type={inputType}
role={suggestions.length > 0 ? 'combobox' : ''}
autoComplete="off"
aria-autocomplete="list"
aria-invalid={isInvalid}
{...(isOpen ? { 'aria-controls': listId } : {})}
{...getClasses(Elements.Input)}
{...getEvents()}
Expand All @@ -417,16 +424,15 @@ export const Email = forwardRef<HTMLInputElement, EmailProps>(
role="option"
ref={(li) => (liRefs.current[i] = li)}
onPointerMove={() => setActiveSuggestion(-1, i)}
onMouseMove={() => setActiveSuggestion(-1, i)}
onPointerLeave={() => setActiveSuggestion(-1, -1)}
onMouseLeave={() => setActiveSuggestion(-1, -1)}
onClick={(e) => handleSelect(e, i, { isKeyboard: false, isInput: false })}
onKeyDown={handleListKeyDown}
key={domain}
aria-posinset={i + 1}
aria-setsize={suggestions.length}
aria-selected={i === activeSuggestion.focusedIndex}
tabIndex={-1}
// This must always be false as no option can be already selected
aria-selected="false"
{...getClasses(Elements.Suggestion)}
{...{
[activeDataAttr ? activeDataAttr : 'data-active-email']: i === activeSuggestion.hoveredIndex,
Expand Down
43 changes: 26 additions & 17 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,31 @@ export type Props = {
classNames?: ClassNames
/** Class name of the wrapper element. */
className?: string
/** Custom prefix for dropdown unique ID. */
customPrefix?: string
/** DOM ID of the wrapper element. */
wrapperId?: string
/** Validity state of the field for assistive technologies. */
isInvalid?: boolean
/** Dropdown `aria-label` value */
dropdownAriaLabel?: string
/** Value of the `data-` attribute to be set on the focuesed/hovered suggestion. Defaults to `data-active-email`. */
activeDataAttr?: `data-${string}`
children?: React.ReactNode
/** Dropdown placement.
*
* @deprecated Since version 0.9.8 dropdown is always placed below the input.
* @deprecated Since version 1.0.0 dropdown is always placed below the input.
*/
placement?: 'auto' | 'bottom'
/** Custom prefix for dropdown unique ID.
*
* @deprecated Since version 1.2.0 it is generated automatically.
*/
customPrefix?: string
/** DOM ID of the wrapper element.
*
* @deprecated Since version 1.2.0
*/
wrapperId?: string
/** Input field validity state for assistive technologies.
*
* @deprecated Since version 1.2.0. Use `aria-invalid` instead.
*/
isInvalid?: boolean
}

export type Events = {
Expand All @@ -68,19 +77,19 @@ export type Events = {
onInput?: React.FormEventHandler<HTMLInputElement>
}

export type InternalInputProps =
| 'ref'
| 'aria-expanded'
| 'type'
| 'role'
| 'autoComplete'
| 'aria-autocomplete'
| 'aria-controls'

export type EmailProps = Props &
Events &
Partial<
Omit<
React.HTMLProps<HTMLInputElement>,
'onChange' | 'value' | 'onSelect' | 'onFocus' | 'onBlur' | 'onKeyDown' | 'onInput' | 'ref' | 'className'
>
>
Partial<Omit<React.HTMLProps<HTMLInputElement>, keyof Props | keyof Events | InternalInputProps>>

export type LocalizedList = {
default: string[]
} & Record<string, string[]>

/** List of ~160 world's most popular email providers.
* Meant to be used with `refineList` prop. */
export declare const domains: string[]
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const alphanumericKeys = /^[a-z0-9]$/i
export const alphanumericKeys = /^[a-z0-9@.]$/i

export function cleanValue(value: string) {
return value.replace(/\s+/g, '').toLowerCase()
Expand Down
Loading

0 comments on commit 2f4b31a

Please sign in to comment.