Allow mobile search behaviour

Make focus/blur more robust

Allow Clear and Cancel button contents to be passed as components
This commit is contained in:
Tom Southall
2022-03-03 23:48:26 +00:00
parent 7e8cc776ce
commit 7432818a22
14 changed files with 233 additions and 45 deletions

View File

@@ -5,6 +5,8 @@ import autocompleteStyles from './styles/autocomplete.module.css'
import { defaultListboxNoRecentSearches } from '../_shared/defaultListbox'
import ItemContents from './components/itemContents/itemContents'
import GroupName from './components/groupName/groupName'
import CancelButton from './components/cancelButton/cancelButton'
import ClearButton from './components/clearButton/clearButton'
import recentSearchesPlugin from '../../plugins/turnstone-recent-searches'
import undef from '../../src/lib/utils/undef'
@@ -63,6 +65,7 @@ const App = () => {
<label htmlFor="autocomplete">Search:</label>&nbsp;
<Turnstone
autoFocus={false}
cancelButton={true}
clearButton={true}
debounceWait={250}
defaultListbox={defaultListboxNoRecentSearches}
@@ -80,6 +83,8 @@ const App = () => {
placeholder={placeholder}
plugins={[[recentSearchesPlugin, {ratio: 2, name: 'Recent'}]]}
styles={autocompleteStyles}
Cancel={CancelButton}
Clear={ClearButton}
GroupName={GroupName}
ItemContents={ItemContents}
/>

View File

@@ -0,0 +1,12 @@
import React from 'react'
import styles from './cancelButton.module.css'
const DefaultCancel = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" className={styles.svg} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 17l-5-5m0 0l5-5m-5 5h12" />
</svg>
)
}
export default DefaultCancel

View File

@@ -0,0 +1,4 @@
.svg {
width: 28px;
height: 2.5em;
}

View File

@@ -0,0 +1,12 @@
import React from 'react'
import styles from './clearButton.module.css'
const DefaultClear = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" className={styles.svg} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
}
export default DefaultClear

View File

@@ -0,0 +1,4 @@
.svg {
width: 22px;
height: 2.5em;
}

View File

@@ -18,6 +18,12 @@
font-size: 20px;
}
.container {
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.container .split1 {
font-size: 15px;
color: #777;

View File

@@ -1,11 +1,40 @@
.queryContainer {
.container {
position: relative;
display: inline-block;
font-size: 16px;
}
.containerFocus {
position: fixed;
display: block;
width: 100%;
height: 100%;
top: 0px;
left: 0px;
background: #fff;
z-index: 989;
overflow: auto;
font-size: 14px;
}
@media (min-width: 640px) {
.containerFocus {
position: relative;
display: inline-block;
width: auto;
height: auto;
top: auto;
left: auto;
z-index: 0;
overflow: visible;
font-size: 16px;
}
}
.query,
.typeahead {
box-sizing: border-box;
width: 500px;
width: 100%;
height: 2.5em;
line-height: normal;
font-size: 1.4em;
@@ -15,33 +44,103 @@
border: 1px solid #ccc;
}
.containerFocus .query,
.containerFocus .typeahead {
border-top: none;
border-left: none;
border-right: none;
border-bottom: 1px solid #ccc;
padding: 0 1.5em 0 2em;
}
@media (min-width: 640px) {
.query,
.typeahead {
width: 500px;
font-size: 1.4em;
}
.containerFocus .query,
.containerFocus .typeahead {
border: 1px solid #ccc;
padding: 0 1.5em 0 .5em;
}
}
.typeahead {
color: #a8a8a8;
}
.clearButton {
top: 7px;
right: 10px;
font-size: 30px;
position: absolute;
display: block;
width: 1.5em;
height: 2.5em;
top: 0;
right: 0;
font-size: 1.4em;
color: #a8a8a8;
cursor: pointer;
border: none;
background: transparent;
padding:0;
}
.clearButton:hover {
color: black;
}
@media (min-width: 640px) {
.clearButton {
width: 1.5em;
height: 2.5em;
}
}
.cancelButton {
position: absolute;
display: block;
width: 40px;
height: 2.5em;
top: 0;
left: 0;
font-size: 1.4em;
color: #a8a8a8;
cursor: pointer;
border: none;
background: transparent;
padding:0;
}
.cancelButton:hover {
color: black;
}
@media (min-width: 640px) {
.cancelButton {
display: none;
}
}
.listbox,
.errorbox {
box-sizing: border-box;
width: 500px;
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
border-bottom: 1px solid #ccc;
width: 100%;
border: none;
padding-bottom: 5px;
cursor: default;
background: #fff;
}
@media (min-width: 640px) {
.listbox,
.errorbox {
width: 500px;
border: none;
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
border-bottom: 1px solid #ccc;
}
}
.noItems,
.errorMessage {
margin: 2em auto;
@@ -61,12 +160,15 @@
.item {
margin: 0;
padding: 12px 10px;
font-size: 16px;
}
@media (min-width: 640px) {
.item {
padding: 5px 10px;
font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
}
.highlightedItem {

View File

@@ -27,7 +27,7 @@ const listbox2 = [
const App = () => {
return (
<>
<div>
<div style={{display:'inline-block'}}>
<label htmlFor="autocomplete">Search Fruits:</label>&nbsp;
<Turnstone
autoFocus={true}
@@ -42,7 +42,9 @@ const App = () => {
placeholder={'Type something fruity'}
styles={styles}
/>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</div>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<div style={{display:'inline-block'}}>
<label htmlFor="autocomplete">Search Fruits &amp; Veg:</label>&nbsp;
<Turnstone
autoFocus={true}

View File

@@ -7,12 +7,6 @@ exports[`Container > Component renders correctly 1`] = `
aria-owns="autocomplete-listbox"
className="query-container-class"
role="combobox"
style={
{
"display": "inline-block",
"position": "relative",
}
}
>
<input
aria-autocomplete="both"

View File

@@ -28,9 +28,10 @@ export default function Container(props) {
// Destructure props
const {
autoFocus,
cancelButton,
cancelButtonAriaLabel,
clearButton,
clearButtonAriaLabel,
clearButtonText,
debounceWait,
defaultListbox,
defaultListboxIsImmutable,
@@ -47,7 +48,9 @@ export default function Container(props) {
onEnter,
onTab,
placeholder,
tabIndex
tabIndex,
Cancel,
Clear
} = props
// Destructure listbox prop
@@ -65,6 +68,7 @@ export default function Container(props) {
// Component state
const [debouncedQuery] = useDebounce(state.query, debounceWait)
const [hasFocus, setHasFocus] = useState(false)
const [blockBlurHandler, setBlockBlurHandler] = useState(false)
// DOM references
const queryInput = useRef(null)
@@ -72,8 +76,13 @@ export default function Container(props) {
// Calculated states
const hasClearButton = clearButton && !!state.query
const hasCancelButton = cancelButton && hasFocus
const isExpanded = hasFocus && state.itemsLoaded
const isErrorExpanded = !!props.errorMessage && state.itemsError
const containerClassname = hasFocus ? 'containerFocus' : 'container'
const defaultContainerStyles = customStyles[containerClassname]
? undef
: defaultStyles[containerClassname]
// Checks whether or not SWR data is to be treated as immutable
const isImmutable = (() => {
@@ -156,36 +165,48 @@ export default function Container(props) {
dispatch(setQuery(queryInput.current.value))
}
const handleClearButton = (evt) => {
evt.preventDefault()
const handleClearButton = () => {
setBlockBlurHandler(true)
clearState()
}
const handleCancelButton = () => {
clearState()
}
const clearState = () => {
// Immediately clearing both inputs prevents any slight
// visual timing delays with async dispatch
queryInput.current.vaslue = ''
queryInput.current.value = ''
typeaheadInput.current.value = ''
dispatch(clear())
queryInput.current.focus()
}
const handleFocus = () => {
setHasFocus(true) //TODO: make hasFocus part of global state?
if(!hasFocus) {
setHasFocus(true)
if (state.items && state.items.length > 0) {
dispatch(setHighlighted(0))
}
}
}
const handleBlur = () => {
if(blockBlurHandler) {
queryInput.current.focus()
}
else {
setHasFocus(false)
}
setBlockBlurHandler(false)
}
return (
<React.Fragment>
<div
className={customStyles.queryContainer}
style={defaultStyles.queryContainer}
className={customStyles[containerClassname]}
style={defaultContainerStyles}
role='combobox'
aria-expanded={isExpanded}
aria-owns={listboxId}
@@ -228,13 +249,25 @@ export default function Container(props) {
/>
{hasClearButton && (
<div
<button
className={customStyles.clearButton}
style={defaultStyles.clearButton}
onClick={handleClearButton}
onMouseDown={handleClearButton}
tabIndex={-1}
role='button'
aria-label={clearButtonAriaLabel}>{clearButtonText}</div>
aria-label={clearButtonAriaLabel}>
<Clear />
</button>
)}
{hasCancelButton && (
<button
className={customStyles.cancelButton}
style={defaultStyles.cancelButton}
onMouseDown={handleCancelButton}
tabIndex={-1}
aria-label={cancelButtonAriaLabel}>
<Cancel />
</button>
)}
{isExpanded && (

View File

@@ -23,7 +23,7 @@ vi.mock('use-debounce', vi.fn().mockImplementation(() => ({
describe('Container', () => {
test('Component renders correctly', () => {
const customStyles = {
queryContainer: 'query-container-class',
container: 'query-container-class',
query: 'query-class',
typeahead: 'typeahead-class',
x: 'x-class'

View File

@@ -1,5 +1,8 @@
const styles = {
queryContainer: {
container: {
position: 'relative'
},
containerFocus: {
position: 'relative'
},
query: {
@@ -14,9 +17,10 @@ const styles = {
left: 0
},
clearButton: {
position: 'absolute',
display: 'inline-block',
zIndex: 2
},
cancelButton: {
zIndex: 3
}
}

View File

@@ -10,9 +10,10 @@ const randomId = () => `turnstone-${(0|Math.random()*6.04e7).toString(36)}`
// Set prop defaults before passing them on to components
const propDefaults = {
autoFocus: false,
cancelButton: false,
cancelButtonAriaLabel: 'Cancel',
clearButton: false,
clearButtonAriaLabel: 'Clear contents',
clearButtonText: '\u00d7',
debounceWait: 250,
defaultListboxIsImmutable: true,
disabled: false,
@@ -20,7 +21,9 @@ const propDefaults = {
listboxIsImmutable: true,
maxItems: 10,
minQueryLength: 1,
placeholder: ''
placeholder: '',
Cancel: () => 'Cancel',
Clear: () => '\u00d7'
}
export default function Turnstone(props) {
@@ -109,6 +112,8 @@ Turnstone.propTypes = {
styles: PropTypes.object,
tabIndex: PropTypes.number,
text: PropTypes.string,
Cancel: PropTypes.elementType,
Clear: PropTypes.elementType,
ItemContents: PropTypes.elementType,
GroupName: PropTypes.elementType
}

View File

@@ -28,11 +28,16 @@ describe('Turnstone', () => {
// The id prop is randomly generated so must be excluded
delete props.id
//Do not test default functions
delete props.Cancel
delete props.Clear
expect(props).toEqual({
autoFocus: false,
cancelButton: false,
cancelButtonAriaLabel: 'Cancel',
clearButton: false,
clearButtonAriaLabel: 'Clear contents',
clearButtonText: '×',
debounceWait: 250,
defaultListboxIsImmutable: true,
disabled: false,