mirror of
https://github.com/fergalmoran/turnstone.git
synced 2025-12-27 04:07:46 +00:00
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:
@@ -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>
|
||||
<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}
|
||||
/>
|
||||
|
||||
12
examples/geo/components/cancelButton/cancelButton.jsx
Normal file
12
examples/geo/components/cancelButton/cancelButton.jsx
Normal 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
|
||||
@@ -0,0 +1,4 @@
|
||||
.svg {
|
||||
width: 28px;
|
||||
height: 2.5em;
|
||||
}
|
||||
12
examples/geo/components/clearButton/clearButton.jsx
Normal file
12
examples/geo/components/clearButton/clearButton.jsx
Normal 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
|
||||
@@ -0,0 +1,4 @@
|
||||
.svg {
|
||||
width: 22px;
|
||||
height: 2.5em;
|
||||
}
|
||||
@@ -18,6 +18,12 @@
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.container .split1 {
|
||||
font-size: 15px;
|
||||
color: #777;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -27,7 +27,7 @@ const listbox2 = [
|
||||
const App = () => {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div style={{display:'inline-block'}}>
|
||||
<label htmlFor="autocomplete">Search Fruits:</label>
|
||||
<Turnstone
|
||||
autoFocus={true}
|
||||
@@ -42,7 +42,9 @@ const App = () => {
|
||||
placeholder={'Type something fruity'}
|
||||
styles={styles}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{display:'inline-block'}}>
|
||||
<label htmlFor="autocomplete">Search Fruits & Veg:</label>
|
||||
<Turnstone
|
||||
autoFocus={true}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user