@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
198 lines (163 loc) • 5.78 kB
JavaScript
/** @jsx jsx */
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'
import PropTypes from 'prop-types'
import debounce from 'lodash/debounce'
import partial from 'lodash/partial'
import {jsx} from '@instructure/emotion'
import {AccessibleContent, ScreenReaderContent} from '@instructure/ui-a11y-content'
import {Tag} from '@instructure/ui-tag'
import {View} from '@instructure/ui-view'
import Errors from '../../../common/edit/components/Errors'
import ContentEditable from '../../../common/edit/components/ContentEditable'
import EditStemController from './editStemController'
import generateStyle from './styles'
import generateComponentTheme from './theme'
import t from '@instructure/quiz-i18n/format-message'
import {withStyleOverrides} from '@instructure/quiz-common/util/withStyleOverrides'
const NBSP_CHAR_CODE = 160
function BaseEditStem(props) {
const {
disabled,
errors,
destroyBlank,
onCreateBlankHotkey,
onStemChange,
setSelectedText,
stemItems,
styles,
makeStyles,
} = props
const [isFocused, setIsFocused] = useState(false)
const controllerRef = useRef(new EditStemController(props))
const stemItemRefsRef = useRef({})
const domRef = useRef(null)
const oldOnSelectionChangeRef = useRef(null)
const prevStemItemsRef = useRef(stemItems)
controllerRef.current.updateProps(props)
useEffect(() => {
const oldOnSelectionChange = document.onselectionchange
const newOnSelectionChange = debounce(() => {
const sel = window.getSelection()
const anchor = sel.anchorNode
if (!anchor || !domRef.current) return
if (
domRef.current.contains(anchor) ||
// next line needed because of weird IE11 behavior
domRef.current.contains(anchor.parentNode)
) {
setSelectedText(sel.toString())
}
}, 100)
document.onselectionchange = e => {
oldOnSelectionChange && oldOnSelectionChange(e)
newOnSelectionChange()
}
oldOnSelectionChangeRef.current = oldOnSelectionChange
return () => {
document.onselectionchange = oldOnSelectionChangeRef.current
}
}, [setSelectedText])
useLayoutEffect(() => {
makeStyles()
if (!isFocused) {
prevStemItemsRef.current = stemItems
return
}
// Check if stemItems structurally changed (blank added/removed)
const prevProps = {stemItems: prevStemItemsRef.current}
const focusData = controllerRef.current.getFocusData(prevProps)
// Update ref after checking for changes
prevStemItemsRef.current = stemItems
if (!focusData) {
return
}
const focusRef = stemItemRefsRef.current[focusData.stemItem.id]
if (focusRef) {
focusData.stemItem.type === 'blank'
? focusRef.focus()
: focusRef.focusAndSetCursorOnPosition(focusData.cursorPosition)
}
})
const handlePaste = (stemItem, e) => {
controllerRef.current.handlePaste(e)
onStemChange(stemItem, e)
}
const setFocus = focusState => {
setIsFocused(focusState)
}
const renderContentEditable = stemItem => {
const usePlaceholder = stemItems.length <= 1
const placeholder = usePlaceholder ? t('Type a statement...') : ''
let content = controllerRef.current.textForStemItem(stemItem)
if (!content || content === String.fromCharCode(NBSP_CHAR_CODE)) {
// nbsp is used to give a width to a final stem item if blank
// if we only have one stem, just use the placeholder to do this
content = usePlaceholder ? '' : String.fromCharCode(NBSP_CHAR_CODE)
}
return (
<ContentEditable
label={<ScreenReaderContent>{t('Statement')}</ScreenReaderContent>}
key={stemItem.id}
id={stemItem.id}
ref={span => {
stemItemRefsRef.current[stemItem.id] = span
}}
placeholder={placeholder}
enableFormatting={false}
disabled={disabled}
content={content}
onChange={partial(onStemChange, stemItem)}
onPaste={partial(handlePaste, stemItem)}
onKeyDown={onCreateBlankHotkey}
customStyles={styles.editableText}
/>
)
}
const renderBlank = stemItem => {
const dismissible = !disabled
const text = controllerRef.current.textForStemItem(stemItem)
return (
<Tag
key={stemItem.id}
dismissible={dismissible}
elementRef={button => {
stemItemRefsRef.current[stemItem.id] = button
}}
onClick={dismissible ? partial(destroyBlank, stemItem) : null}
size="large"
text={
<AccessibleContent alt={t('Delete "{ text }" blank', {text})}>{text}</AccessibleContent>
}
variant="inline"
/>
)
}
const renderStemItem = stemItem => {
return stemItem.type === 'text' ? renderContentEditable(stemItem) : renderBlank(stemItem)
}
stemItemRefsRef.current = {}
return (
<div ref={domRef} onFocus={partial(setFocus, true)} onBlur={partial(setFocus, false)}>
<Errors errorList={errors}>{stemItems.map(renderStemItem)}</Errors>
<View as="div" borderWidth="small none none none" margin="small none" />
</div>
)
}
BaseEditStem.displayName = 'EditStem'
BaseEditStem.componentId = 'QuizzesEditStem'
BaseEditStem.propTypes = {
disabled: PropTypes.bool,
errors: PropTypes.array,
destroyBlank: PropTypes.func.isRequired,
onCreateBlankHotkey: PropTypes.func.isRequired,
onStemChange: PropTypes.func.isRequired,
setSelectedText: PropTypes.func.isRequired,
stemItems: PropTypes.array.isRequired,
styles: PropTypes.object,
makeStyles: PropTypes.func,
}
BaseEditStem.defaultProps = {
disabled: false,
errors: void 0,
}
export default withStyleOverrides(generateStyle, generateComponentTheme)(BaseEditStem)