AJoute un champs de type Number (work in progress)

Feature :
- Supporte les unités
- Supporte les valeurs au clavier (haut / bas)
- Supporte le formatting
wip-johan
Johan Girod 2021-10-19 17:38:19 +02:00
parent 90a55cc285
commit 1a36518f61
20 changed files with 419 additions and 602 deletions

View File

@ -25,6 +25,7 @@
"@babel/preset-env": "^7.9.5",
"@babel/preset-react": "^7.9.4",
"@babel/preset-typescript": "^7.9.0",
"@react-types/numberfield": "^3.1.0",
"@react-types/searchfield": "^3.1.2",
"@types/cheerio": "^0.22.18",
"@types/js-yaml": "^3.12.2",
@ -65,9 +66,12 @@
"dependencies": {
"@babel/runtime": "^7.3.4",
"@react-aria/button": "^3.3.4",
"@react-aria/i18n": "^3.3.2",
"@react-aria/numberfield": "^3.1.0",
"@react-aria/searchfield": "^3.2.0",
"@react-aria/textfield": "^3.4.0",
"@react-pdf/renderer": "^1.6.10",
"@react-stately/numberfield": "^3.0.2",
"@react-stately/searchfield": "^3.1.3",
"@rehooks/local-storage": "^2.1.1",
"@sentry/react": "^6.3.5",
@ -95,7 +99,6 @@
"react-instantsearch-dom": "^6.11.2",
"react-markdown": "^4.1.0",
"react-monaco-editor": "^0.40.0",
"react-number-format": "^4.3.1",
"react-redux": "^7.0.3",
"react-router-dom": "^5.1.1",
"react-router-hash-link": "^1.2.2",

View File

@ -1,29 +0,0 @@
.currencyInput__container {
display: flex !important;
align-items: center;
justify-content: flex-end;
}
.currencyInput__input:focus {
outline: none;
}
.currencyInput__input {
height: inherit;
max-height: inherit;
border: none;
text-align: inherit;
font-family: inherit;
padding: 0;
font-weight: inherit;
min-width: 0;
outline: none;
margin: 0;
width: inherit;
color: inherit;
background-color: transparent;
font-size: inherit;
}
.currencyInput__input::-ms-clear {
display: none;
}

View File

@ -1,167 +0,0 @@
import { expect } from 'chai'
import { mount, shallow } from 'enzyme'
import { match, spy, useFakeTimers } from 'sinon'
import CurrencyInput from './CurrencyInput'
let getInput = (component) => mount(component).find('input')
describe('CurrencyInput', () => {
it('should render an input', () => {
expect(getInput(<CurrencyInput />)).to.have.length(1)
})
it('should accept , as decimal separator in french', () => {
const onChange = spy()
const input = getInput(<CurrencyInput language="fr" onChange={onChange} />)
input.simulate('change', { target: { value: '12,1', focus: () => {} } })
expect(onChange).to.have.been.calledWith(
match.hasNested('target.value', '12.1')
)
})
it('should separate thousand groups', () => {
const input1 = getInput(
<CurrencyInput value={1000} language="fr" currencySymbol={''} />
)
const input2 = getInput(
<CurrencyInput value={1000} language="en" currencySymbol={''} />
)
const input3 = getInput(
<CurrencyInput value={1000.5} language="en" currencySymbol={''} />
)
const input4 = getInput(
<CurrencyInput value={1000000} language="en" currencySymbol={''} />
)
expect(input1.instance().value).to.equal('1 000')
expect(input2.instance().value).to.equal('1,000')
expect(input3.instance().value).to.equal('1,000.5')
expect(input4.instance().value).to.equal('1,000,000')
})
it('should handle decimal separator', () => {
const input = getInput(<CurrencyInput value={0.5} language="fr" />)
expect(input.instance().value).to.equal('0,5')
})
it('should accept negative number', () => {
let onChange = spy()
const input = getInput(<CurrencyInput onChange={onChange} />)
input.simulate('change', { target: { value: '-12', focus: () => {} } })
expect(onChange).to.have.been.calledWith(
match.hasNested('target.value', '-12')
)
})
it('should not accept anything else than number', () => {
let onChange = spy()
const input = getInput(<CurrencyInput language="fr" onChange={onChange} />)
input.simulate('change', { target: { value: '*1/2abc3', focus: () => {} } })
expect(onChange).to.have.been.calledWith(
match.hasNested('target.value', '123')
)
})
it('should pass other props to the input', () => {
const input = getInput(<CurrencyInput autoFocus />)
expect(input.prop('autoFocus')).to.be.true
})
it('should not call onChange while the decimal part is being written', () => {
let onChange = spy()
const input = getInput(
<CurrencyInput language="fr" value="111" onChange={onChange} />
)
input.simulate('change', { target: { value: '111,', focus: () => {} } })
expect(onChange).not.to.have.been.called
})
it('should change the position of the currency symbol depending on the language', () => {
const inputFr = shallow(<CurrencyInput language="fr" />)
expect(inputFr.children().last().text()).to.includes('€')
const inputEn = shallow(<CurrencyInput language="en" />)
expect(inputEn.children().first().text()).to.includes('€')
})
it('should debounce onChange call', () => {
const clock = useFakeTimers()
let onChange = spy()
const input = getInput(
<CurrencyInput
language="fr"
onChange={onChange}
debounce={1000}
currencySymbol={''}
/>
)
input.simulate('change', { target: { value: '1', focus: () => {} } })
expect(onChange).not.to.have.been.called
clock.tick(500)
input.simulate('change', { target: { value: '12', focus: () => {} } })
clock.tick(600)
expect(onChange).not.to.have.been.called
clock.tick(400)
expect(onChange).to.have.been.calledWith(
match.hasNested('target.value', '12')
)
clock.restore()
})
it('should initialize with value of the value prop', () => {
const input = getInput(<CurrencyInput value={1} language="fr" />)
expect(input.instance().value).to.equal('1')
})
it('should update its value if the value prop changes', () => {
const component = mount(<CurrencyInput value={1} language="fr" />)
component.setProps({ value: 2 })
expect(component.find('input').instance().value).to.equal('2')
})
it('should not call onChange the value is the same as the current input value', () => {
let onChange = spy()
const wrapper = mount(
<CurrencyInput language="fr" value={2000} onChange={onChange} />
)
const input = wrapper.find('input')
input.simulate('change', { target: { value: '2000', focus: () => {} } })
wrapper.setProps({ value: '2000' })
expect(onChange).not.to.have.been.called
})
it('should adapt its size to its content', () => {
const wrapper = mount(<CurrencyInput language="fr" value={1000} />)
// It would be better to use `input.offsetWidth` but it's not supported by
// Enzyme/JSDOM
const getInlineWidth = () =>
getComputedStyle(
wrapper.find('.currencyInput__container').getDOMNode()
).getPropertyValue('width')
expect(getInlineWidth()).to.equal('')
wrapper.setProps({ value: '1000000' })
expect(Number(getInlineWidth().replace(/em$/, ''))).to.be.greaterThan(5)
})
it('should not call onChange if the value is not a correct number', () => {
let onChange = spy()
mount(<CurrencyInput language="fr" onChange={onChange} />)
.find('input')
.simulate('change', {
target: { value: '-', focus: () => {} },
})
mount(<CurrencyInput language="fr" onChange={onChange} />)
.find('input')
.simulate('change', {
target: { value: ',', focus: () => {} },
})
mount(<CurrencyInput language="fr" onChange={onChange} />)
.find('input')
.simulate('change', {
target: { value: ',5', focus: () => {} },
})
mount(<CurrencyInput language="fr" onChange={onChange} />)
.find('input')
.simulate('change', {
target: { value: '8,', focus: () => {} },
})
expect(onChange).not.to.have.been.called
})
})

View File

@ -1,108 +0,0 @@
import classnames from 'classnames'
import React, { useMemo, useRef, useState } from 'react'
import NumberFormat, { NumberFormatProps } from 'react-number-format'
import { currencyFormat, debounce } from '../../utils'
import './CurrencyInput.css'
type CurrencyInputProps = NumberFormatProps & {
value?: string | number | null
debounce?: number
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void
currencySymbol?: string
language: string
}
export default function CurrencyInput({
value,
debounce: debounceTimeout,
currencySymbol = '€',
onChange,
language,
missing,
className,
style,
dottedName,
...forwardedProps
}: CurrencyInputProps) {
const valueProp =
typeof value === 'number' && Number.isNaN(value) ? '' : value ?? ''
const [initialValue, setInitialValue] = useState(valueProp)
const [currentValue, setCurrentValue] = useState(valueProp)
const onChangeDebounced = useMemo(
() =>
debounceTimeout && onChange
? debounce(debounceTimeout, onChange)
: onChange,
[onChange, debounceTimeout]
)
// We need some mutable reference because the <NumberFormat /> component doesn't provide
// the DOM `event` in its custom `onValueChange` handler
const nextValue = useRef('')
const inputRef = useRef<HTMLInputElement>()
// When the component is rendered with a new "value" prop, we reset our local
// state
if (valueProp !== initialValue) {
setCurrentValue(valueProp)
setInitialValue(valueProp)
}
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
// Only trigger the `onChange` event if the value has changed -- and not
// only its formating, we don't want to call it when a dot is added in `12.`
// for instance
if (!nextValue.current || /(\.$)|(^\.)|(-$)/.exec(nextValue.current)) {
return
}
event.persist()
event.target = {
...event.target,
value: nextValue.current,
}
nextValue.current = ''
onChangeDebounced?.(event)
}
const { isCurrencyPrefixed, thousandSeparator, decimalSeparator } =
currencyFormat(language)
// Autogrow the input
const valueLength = currentValue.toString().length
const width = `${5 + (valueLength - 5) * 0.75}em`
return (
<div
className={classnames(className, 'currencyInput__container')}
style={{ ...(valueLength > 5 ? { width } : {}), ...style }}
onFocus={() => inputRef.current?.select()}
onClick={() => inputRef.current?.focus()}
>
{isCurrencyPrefixed && currentValue == '' && <>&nbsp;</>}
<NumberFormat
{...forwardedProps}
thousandSeparator={thousandSeparator}
decimalSeparator={decimalSeparator}
allowNegative
className="currencyInput__input"
inputMode="numeric"
getInputRef={inputRef}
prefix={
isCurrencyPrefixed && currencySymbol ? `${currencySymbol} ` : ''
}
onValueChange={({ value }) => {
setCurrentValue(value)
nextValue.current = value
.toString()
.replace(/^0+(.*)$/, '$1')
.replace(/^$/, '0')
}}
onChange={handleChange}
value={currentValue != null ? currentValue : ''}
autoComplete="off"
/>
{!isCurrencyPrefixed && <>&nbsp;</>}
</div>
)
}

View File

@ -1,97 +0,0 @@
.range {
-webkit-appearance: none;
vertical-align: middle;
outline: none;
border: none;
cursor: pointer;
padding: 0;
background: none;
}
.range::-webkit-slider-runnable-track {
background-color: white;
height: 6px;
border-radius: 3px;
border: 1px solid transparent;
}
.range[disabled]::-webkit-slider-runnable-track {
border: 1px solid white;
background-color: transparent;
opacity: 0.4;
}
.range::-moz-range-track {
background-color: white;
height: 6px;
border-radius: 3px;
border: none;
}
.range::-ms-track {
color: transparent;
border: none;
background: none;
height: 6px;
}
.range::-ms-fill-lower {
background-color: white;
border-radius: 3px;
}
.range::-ms-fill-upper {
background-color: white;
border-radius: 3px;
}
.range::-ms-tooltip {
display: none; /* display and visibility only */
}
.range::-moz-range-thumb {
border-radius: 20px;
height: 18px;
width: 18px;
border: 2px solid white;
background: none;
background-color: var(--color);
cursor: pointer;
}
.range:active::-moz-range-thumb {
outline: none;
}
.range::-webkit-slider-thumb {
-webkit-appearance: none !important;
border-radius: 100%;
border: 2px solid white;
background-color: var(--color);
cursor: pointer;
height: 18px;
width: 18px;
margin-top: -7px;
}
.range[disabled]::-webkit-slider-thumb {
background-color: transparent;
border: 1px solid white;
}
.range:active::-webkit-slider-thumb {
outline: none;
}
.range::-ms-thumb {
border-radius: 100%;
border: 2px solid white;
background-color: var(--color);
cursor: pointer;
height: 18px;
width: 18px;
}
.range:active::-ms-thumb {
border: none;
}

View File

@ -1,48 +0,0 @@
import { formatValue } from 'publicodes'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { debounce as debounceFn } from '../utils'
import { InputProps } from './conversation/RuleInput'
import './PercentageField.css'
type PercentageFieldProps = InputProps & {
debounce: number
}
export default function PercentageField({
onChange,
value,
debounce = 0,
}: PercentageFieldProps) {
const [localValue, setLocalValue] = useState(value as number)
const debouncedOnChange = useCallback(
debounce ? debounceFn(debounce, onChange) : onChange,
[debounce, onChange]
)
const language = useTranslation().i18n.language
return (
<div>
<input
className="range"
onChange={(e) => {
const value = e.target.value
setLocalValue(+value)
debouncedOnChange(value)
}}
type="range"
value={localValue}
name="volume"
min="0"
step="0.05"
max="1"
/>
<span style={{ display: 'inline-block', width: '3em' }}>
{formatValue(localValue, {
language,
displayedUnit: '%',
})}
</span>
</div>
)
}

View File

@ -155,6 +155,9 @@ export function SimulationGoal({
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onChange={onChange}
formatOptions={{
maximumFractionDigits: 0,
}}
useSwitch
/>
) : (

View File

@ -118,10 +118,6 @@
text-decoration: none;
}
#targetSelection input {
margin: 2.7px 0;
}
#targetSelection .targetInput {
width: 5.5em;
max-width: 7.5em;

View File

@ -24,7 +24,7 @@ import {
targetUnitSelector,
} from 'Selectors/simulationSelectors'
import InputSuggestions from './conversation/InputSuggestions'
import CurrencyInput from './CurrencyInput/CurrencyInput'
import NumberInput from './conversation/NumberInput'
import './TargetSelection.css'
import { Appear, FromTop } from './ui/animate'
import Emoji from './utils/Emoji'
@ -177,10 +177,10 @@ function TargetInputOrValue({
const isSituationEmpty = Object.keys(situation).length === 0
const isActive = target.dottedName in situation
const onChange = useCallback(
(evt) =>
(valeur) =>
dispatch(
updateSituation(target.dottedName, {
valeur: evt.target.value,
valeur,
unité: targetUnit,
})
),
@ -195,19 +195,10 @@ function TargetInputOrValue({
{target.question ? (
<>
{!isFocused && <AnimatedTargetValue value={value} />}
<CurrencyInput
debounce={750}
<NumberInput
name={target.dottedName}
unit={{ numerators: ['€'], denominators: [] }}
value={value}
className={classnames(
isFocused ||
isActive ||
isSituationEmpty ||
(target.question && isSmallTarget)
? 'targetInput'
: 'editableTarget',
{ focused: isFocused }
)}
onChange={onChange}
onFocus={() => {
setFocused(true)

View File

@ -1,62 +0,0 @@
import { formatValue, Unit } from 'publicodes'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import NumberFormat from 'react-number-format'
import { currencyFormat, debounce } from '../../utils'
import InputSuggestions from './InputSuggestions'
import { InputProps } from './RuleInput'
// TODO: fusionner Input.js et CurrencyInput.js
export default function Input({
suggestions,
onChange,
onSubmit,
id,
value,
missing,
unit,
autoFocus,
}: InputProps & {
unit: Unit | undefined
}) {
const debouncedOnChange = useCallback(debounce(550, onChange), [])
const { language } = useTranslation().i18n
const unité = formatValue({ nodeValue: value ?? 0, unit }, { language })
.replace(/[\d,.]/g, '')
.trim()
const { thousandSeparator, decimalSeparator } = currencyFormat(language)
// const [currentValue, setCurrentValue] = useState(value)
return (
<div className="step input">
<div>
<InputSuggestions
suggestions={suggestions}
onFirstClick={(value) => {
onChange(value)
}}
onSecondClick={() => onSubmit?.('suggestion')}
/>
<NumberFormat
autoFocus={autoFocus}
className="suffixed ui__"
id={id}
thousandSeparator={thousandSeparator}
decimalSeparator={decimalSeparator}
allowEmptyFormatting={true}
// We don't want to call `onValueChange` in case this component is
// re-render with a new "value" prop from the outside.
onValueChange={({ floatValue }) => {
if (floatValue !== value) {
debouncedOnChange(
floatValue != undefined ? { valeur: floatValue, unité } : {}
)
}
}}
autoComplete="off"
{...{ [missing ? 'placeholder' : 'value']: value ?? '' }}
/>
<span className="suffix">&nbsp;{unité}</span>
</div>
</div>
)
}

View File

@ -0,0 +1,139 @@
import { NumberField } from 'DesignSystem/field'
import { serializeUnit, Unit } from 'publicodes'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { debounce } from '../../utils'
import InputSuggestions from './InputSuggestions'
import { InputProps } from './RuleInput'
export default function NumberInput({
suggestions,
onChange,
onSubmit,
value,
formatOptions,
missing,
unit,
autoFocus,
}: InputProps & {
unit: Unit | undefined
}) {
const unité = serializeUnit(unit)
const [currentValue, handleChange] = useState<number | undefined>(
!missing && value != null && typeof value === 'number' ? value : undefined
)
const language = useTranslation().i18n.language
const displayedUnit =
unit && getSerializedUnit(currentValue ?? 0, unit, language)
useEffect(() => {
if (!missing && value != null && typeof value === 'number') {
handleChange(value)
}
}, [value])
formatOptions = {
style: 'decimal',
...(unit?.numerators.includes('€')
? {
style: 'currency',
currency: 'EUR',
}
: {}),
...formatOptions,
}
const debouncedOnChange = useCallback(debounce(1000, onChange), [])
return (
<div className="step input">
<div>
<InputSuggestions
suggestions={suggestions}
onFirstClick={(value) => {
handleChange(value)
setImmediate(() => {
onChange(value)
})
}}
onSecondClick={() => onSubmit?.('suggestion')}
/>
<NumberField
autoFocus={autoFocus}
displayedUnit={displayedUnit}
onChange={(valeur) => {
handleChange(valeur)
if (!Number.isNaN(valeur)) {
debouncedOnChange({ valeur, unité })
}
}}
formatOptions={formatOptions}
placeholder={missing && value != null ? value : undefined}
value={currentValue}
/>
</div>
</div>
)
}
// TODO : put this inside publicodes
function getSerializedUnit(value: number, unit: Unit, locale: string): string {
// removing euro, which is a currency not a unit
unit = {
...unit,
numerators: unit.numerators.filter((unit) => unit !== '€'),
}
if (Number.isNaN(value)) {
value = 0
}
const formatUnit = getFormatUnit(unit)
if (!formatUnit) {
return serializeUnit(unit) ?? ''
}
return (
Intl.NumberFormat(locale, {
unit: formatUnit,
style: 'unit',
unitDisplay: 'long',
})
.formatToParts(value)
.find(({ type }) => type === 'unit')?.value ?? ''
)
}
// https://tc39.es/proposal-unified-intl-numberformat/section6/locales-currencies-tz_proposed_out.html#sec-issanctionedsimpleunitidentifier
const UNIT_MAP = {
heure: 'hour',
jour: 'day',
année: 'year',
an: 'year',
minute: 'minute',
mois: 'month',
second: 'second',
semaine: 'week',
} as const
function getFormatUnit(unit: Unit): Intl.NumberFormatOptions['unit'] | null {
if (unit.numerators.length !== 1 || unit.denominators.length > 1) {
return null
}
const numerator = unit.numerators[0]
const denominator = unit.denominators[0]
if (
(numerator && !(numerator in UNIT_MAP)) ||
(denominator && !(denominator in UNIT_MAP))
) {
return null
}
let formatUnit = ''
if (numerator) {
formatUnit += UNIT_MAP[numerator as keyof typeof UNIT_MAP]
}
if (denominator) {
formatUnit += `-per-${UNIT_MAP[denominator as keyof typeof UNIT_MAP]}`
}
return formatUnit
}

View File

@ -1,9 +1,8 @@
import Input from 'Components/conversation/Input'
import NumberInput from 'Components/conversation/NumberInput'
import Question, { Choice } from 'Components/conversation/Question'
import SelectCommune from 'Components/conversation/select/SelectCommune'
import SelectAtmp from 'Components/conversation/select/SelectTauxRisque'
import CurrencyInput from 'Components/CurrencyInput/CurrencyInput'
import PercentageField from 'Components/PercentageField'
import ToggleSwitch from 'Components/ui/ToggleSwitch'
import { EngineContext } from 'Components/utils/EngineContext'
import { DottedName } from 'modele-social'
@ -45,6 +44,7 @@ type Props<Name extends string = DottedName> = Omit<
isTarget?: boolean
onSubmit?: (source: string) => void
modifiers?: Record<string, string>
formatOptions: Intl.NumberFormatOptions
}
export type InputProps<Name extends string = string> = Omit<
@ -82,7 +82,6 @@ export default function RuleInput({
const engine = useContext(EngineContext)
const rule = engine.getRule(dottedName)
const evaluation = engine.evaluate({ valeur: dottedName, ...modifiers })
const language = useTranslation().i18n.language
const value = evaluation.nodeValue
const commonProps: InputProps<DottedName> = {
dottedName,
@ -154,34 +153,6 @@ export default function RuleInput({
)
}
if (evaluation.unit?.numerators.includes('€') && isTarget) {
const unité = formatValue(
{ nodeValue: value ?? 0, unit: evaluation.unit },
{ language }
)
.replace(/[\d,.]/g, '')
.trim()
return (
<>
<CurrencyInput
className="targetInput"
language={language}
debounce={750}
name={dottedName}
{...commonProps}
onSubmit={() => {}}
onChange={(evt) =>
commonProps.onChange({ valeur: evt.target.value, unité })
}
value={value as number}
/>
</>
)
}
if (evaluation.unit?.numerators.includes('%') && isTarget) {
return <PercentageField {...commonProps} debounce={600} />
}
if (rule.rawNode.type === 'texte') {
return <TextInput {...commonProps} value={value as Evaluation<string>} />
}
@ -192,7 +163,7 @@ export default function RuleInput({
}
return (
<Input
<NumberInput
{...commonProps}
onSubmit={onSubmit}
unit={evaluation.unit}

View File

@ -30,21 +30,11 @@ fieldset {
min-width: 0;
}
/* Remove spinner controls from Firefox */
input[type='number'] {
appearance: textfield;
}
select {
width: auto;
height: auto;
}
input {
line-height: normal;
height: auto;
}
label {
font-size: 100%;
font-weight: normal;

View File

@ -0,0 +1,133 @@
import { useLocale } from '@react-aria/i18n'
import { useNumberField } from '@react-aria/numberfield'
import {
NumberFieldStateProps,
useNumberFieldState,
} from '@react-stately/numberfield'
import { AriaNumberFieldProps } from '@react-types/numberfield'
import { InputHTMLAttributes, useCallback, useRef } from 'react'
import styled from 'styled-components'
import {
StyledContainer,
StyledDescription,
StyledErrorMessage,
StyledInput,
StyledInputContainer,
StyledLabel,
StyledSuffix,
} from './TextField'
function useTweakedNumberFieldState(props: NumberFieldStateProps) {
const state = useNumberFieldState(props)
// 1 - Add the equivalence between , and . for decimal in french
if (props.locale.startsWith('fr')) {
const match = state.inputValue.match(/([^.]*)\.([^.]*)/)
if (match) {
state.setInputValue(`${match[1]},${match[2]}`)
}
}
// const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null)
// useEffect(() => {
// const clearCurrentTimeout = () => {
// if (timeoutId.current) {
// return clearTimeout(timeoutId.current)
// }
// }
// clearCurrentTimeout()
// timeoutId.current = setTimeout(
// () =>
// state.inputValue && state.validate(state.inputValue) && state.commit(),
// 2000
// )
// return clearCurrentTimeout
// }, [state.inputValue])
return state
}
export default function NumberField(
props: AriaNumberFieldProps & { displayedUnit?: string }
) {
const { locale } = useLocale()
const state = useTweakedNumberFieldState({
...props,
locale,
})
const ref = useRef<HTMLInputElement>(null)
const {
labelProps,
inputProps,
descriptionProps,
errorMessageProps,
groupProps,
} = useNumberField(props, state, ref)
const handleClickOnUnit = useCallback(() => {
if (!ref.current) {
return
}
ref.current.focus()
const length = ref.current.value.length * 2
ref.current.setSelectionRange(length * 2, length * 2)
}, [])
const handleDoubleClickOnUnit = useCallback(() => {
if (!ref.current) {
return
}
ref.current.focus()
const length = ref.current.value.length * 2
ref.current.setSelectionRange(0, length * 2)
}, [])
return (
<StyledContainer>
<StyledInputContainer
{...groupProps}
hasError={!!props.errorMessage || props.validationState === 'invalid'}
hasLabel={!!props.label}
>
<StyledNumberInput
{...(inputProps as InputHTMLAttributes<HTMLInputElement>)}
placeholder={props.placeholder ?? ''}
ref={ref}
/>
{props.displayedUnit && (
<StyledUnit
onClick={handleClickOnUnit}
onDoubleClick={handleDoubleClickOnUnit}
>
&nbsp;{props.displayedUnit}
</StyledUnit>
)}
{props.label && (
<StyledLabel {...labelProps}>{props.label}</StyledLabel>
)}
</StyledInputContainer>
{props.errorMessage && (
<StyledErrorMessage {...errorMessageProps}>
{props.errorMessage}
</StyledErrorMessage>
)}
{props.description && (
<StyledDescription {...descriptionProps}>
{props.description}
</StyledDescription>
)}
</StyledContainer>
)
}
const StyledUnit = styled(StyledSuffix)`
color: ${({ theme }) => theme.colors.extended.grey[600]};
padding-left: 0 !important;
white-space: nowrap;
`
const StyledNumberInput = styled(StyledInput)`
padding-right: 0 !important;
text-align: right;
`

View File

@ -26,14 +26,17 @@ export default function SearchField(props: AriaSearchFieldProps) {
return (
<StyledContainer>
<StyledInputContainer
error={!!props.errorMessage || props.validationState === 'invalid'}
hasError={!!props.errorMessage || props.validationState === 'invalid'}
hasLabel={!!props.label}
>
<StyledInput
{...(inputProps as InputHTMLAttributes<HTMLInputElement>)}
placeholder={inputProps.placeholder ?? ''}
ref={ref}
/>
<StyledLabel {...labelProps}>{props.label}</StyledLabel>
{props.label && (
<StyledLabel {...labelProps}>{props.label}</StyledLabel>
)}
{state.value !== '' && (
<StyledClearButton {...clearButtonProps}>×</StyledClearButton>
)}

View File

@ -12,14 +12,17 @@ export default function TextField(props: AriaTextFieldOptions) {
return (
<StyledContainer>
<StyledInputContainer
error={!!props.errorMessage || props.validationState === 'invalid'}
hasError={!!props.errorMessage || props.validationState === 'invalid'}
hasLabel={!!props.label}
>
<StyledInput
{...(inputProps as InputHTMLAttributes<HTMLInputElement>)}
placeholder={inputProps.placeholder ?? ''}
ref={ref}
/>
<StyledLabel {...labelProps}>{props.label}</StyledLabel>
{props.label && (
<StyledLabel {...labelProps}>{props.label}</StyledLabel>
)}
</StyledInputContainer>
{props.errorMessage && (
<StyledErrorMessage {...errorMessageProps}>
@ -43,21 +46,18 @@ export const StyledContainer = styled.div`
export const StyledInput = styled.input`
font-size: 1rem;
line-height: 1.5rem;
flex: 1;
border: none;
background: none;
font-family: ${({ theme }) => theme.fonts.main};
bottom: 0;
width: 100%;
height: 100%;
outline: none;
position: absolute;
padding: calc(${LABEL_HEIGHT} + ${({ theme }) => theme.spacings.xs})
${({ theme }) => theme.spacings.sm} ${({ theme }) => theme.spacings.xs};
transition: color 0.2s;
`
export const StyledLabel = styled.label`
top: 0%;
left: 0;
pointer-events: none;
transform: translateY(0%);
font-size: 0.75rem;
@ -79,18 +79,29 @@ export const StyledErrorMessage = styled(StyledDescription)`
color: ${({ theme }) => theme.colors.extended.error[400]} !important;
`
export const StyledInputContainer = styled.div<{ error: boolean }>`
export const StyledSuffix = styled.span`
font-size: 1rem;
line-height: 1.5rem;
font-family: ${({ theme }) => theme.fonts.main};
`
export const StyledInputContainer = styled.div<{
hasError: boolean
hasLabel: boolean
}>`
border-radius: ${({ theme }) => theme.box.borderRadius};
border: ${({ theme }) =>
`${theme.box.borderWidth} solid ${theme.colors.extended.grey[500]}`};
outline: transparent solid 1px;
position: relative;
flex-direction: column;
display: flex;
background-color: white;
align-items: center;
transition: all 0.2s;
height: ${({ theme }) => theme.spacings.xxxl};
:focus-within {
outline-color: ${({ theme, error }) =>
error
outline-color: ${({ theme, hasError }) =>
hasError
? theme.colors.extended.error[400]
: theme.colors.bases.primary[600]};
}
@ -102,9 +113,17 @@ export const StyledInputContainer = styled.div<{ error: boolean }>`
color: ${({ theme }) => theme.colors.bases.primary[800]};
}
${StyledInput}:not(:focus):placeholder-shown {
color: transparent;
}
${({ hasLabel }) =>
hasLabel &&
css`
${StyledInput}:not(:focus):placeholder-shown {
color: transparent;
}
${StyledInput}:not(:focus):placeholder-shown + ${StyledSuffix} {
color: transparent;
}
`}
${StyledInput}:not(:focus):placeholder-shown + ${StyledLabel} {
font-size: 1rem;
line-height: 1.5rem;
@ -112,8 +131,8 @@ export const StyledInputContainer = styled.div<{ error: boolean }>`
transform: translateY(-50%);
}
${({ theme, error }) =>
error &&
${({ theme, hasError }) =>
hasError &&
css`
&& {
border-color: ${theme.colors.extended.error[400]};
@ -122,4 +141,10 @@ export const StyledInputContainer = styled.div<{ error: boolean }>`
color: ${theme.colors.extended.error[400]};
}
`}
${StyledInput}, ${StyledSuffix} {
padding: ${({ hasLabel, theme }) =>
css`calc(${hasLabel ? LABEL_HEIGHT : '0rem'} + ${theme.spacings.xs})`}
${({ theme }) => theme.spacings.sm} ${({ theme }) => theme.spacings.xs};
}
`

View File

@ -1,3 +1,4 @@
export { default as TextField } from './TextField'
export { default as SearchField } from './SearchField'
export { default as DateField } from './DateField'
export { default as NumberField } from './NumberField'

View File

@ -9,6 +9,7 @@ import ruleTranslations from './locales/rules-en.yaml'
import translateRules from './locales/translateRules'
import translations from './locales/ui-en.yaml'
import './sentry'
import { I18nProvider } from '@react-aria/i18n'
i18next.addResourceBundle('en', 'translation', translations)
i18next.changeLanguage('en')
@ -21,4 +22,9 @@ const Root = hot(() => (
))
const anchor = document.querySelector('#js')
render(<Root />, anchor)
render(
<I18nProvider locale="en-GB">
<Root />
</I18nProvider>,
anchor
)

View File

@ -6,6 +6,8 @@ import { hot } from 'react-hot-loader/root'
import 'regenerator-runtime/runtime'
import App from './App'
import i18next from './locales/i18n'
import { I18nProvider } from '@react-aria/i18n'
import './sentry'
i18next.changeLanguage('fr')
@ -13,4 +15,9 @@ i18next.changeLanguage('fr')
const Root = hot(() => <App basename="mon-entreprise" rules={rules} />)
const anchor = document.querySelector('#js')
render(<Root />, anchor)
render(
<I18nProvider locale="fr-FR">
<Root />
</I18nProvider>,
anchor
)

View File

@ -3076,7 +3076,7 @@
"@react-aria/utils" "^3.8.2"
"@react-types/shared" "^3.8.0"
"@react-aria/interactions@^3.6.0":
"@react-aria/interactions@^3.5.1", "@react-aria/interactions@^3.6.0":
version "3.6.0"
resolved "https://registry.yarnpkg.com/@react-aria/interactions/-/interactions-3.6.0.tgz#63c16e6179e8ae38221e26256d9a7639d7f9b24e"
integrity sha512-dMEGYIIhJ3uxDd19Z/rxuqQp9Rx9c46AInrfzAiOijQj/fTmb4ubCsuFOAQrc0sy1HCY1/ntnRZQuRgT/iS74w==
@ -3095,6 +3095,33 @@
"@react-types/label" "^3.5.0"
"@react-types/shared" "^3.9.0"
"@react-aria/live-announcer@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@react-aria/live-announcer/-/live-announcer-3.0.1.tgz#772888326808d180adc5bc9fa0b4b1416ec08811"
integrity sha512-c63UZ4JhXxy29F6FO1LUkQLDRzv17W4g3QQ+sy6tmFw7R5I5r8uh8jR7RCbBX7bdGCLnQDwOQ055KsM/a9MT3A==
dependencies:
"@babel/runtime" "^7.6.2"
"@react-aria/utils" "^3.8.2"
"@react-aria/visually-hidden" "^3.2.3"
"@react-aria/numberfield@^3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@react-aria/numberfield/-/numberfield-3.1.0.tgz#b9be9930276e8c6ccaba821e775ca67155d8784c"
integrity sha512-szecO5pqd8AiJOcDhj099C+fnuWf0xcB0aUxg7uiikBnTq5RRTMy0P45uVDZneD5Fa7upXcAj4uqMH5+BuJh2A==
dependencies:
"@babel/runtime" "^7.6.2"
"@react-aria/i18n" "^3.3.2"
"@react-aria/interactions" "^3.6.0"
"@react-aria/live-announcer" "^3.0.1"
"@react-aria/spinbutton" "^3.0.1"
"@react-aria/textfield" "^3.4.0"
"@react-aria/utils" "^3.9.0"
"@react-stately/numberfield" "^3.0.2"
"@react-types/button" "^3.4.1"
"@react-types/numberfield" "^3.1.0"
"@react-types/shared" "^3.9.0"
"@react-types/textfield" "^3.3.0"
"@react-aria/searchfield@^3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@react-aria/searchfield/-/searchfield-3.2.0.tgz#f0f8609c2e3a7ed300209f6aa01f6d782c131f81"
@ -3110,6 +3137,18 @@
"@react-types/searchfield" "^3.1.2"
"@react-types/shared" "^3.9.0"
"@react-aria/spinbutton@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@react-aria/spinbutton/-/spinbutton-3.0.1.tgz#e0d5595e1c74518ca46acdeebf7bd19022ee5d50"
integrity sha512-V2wUhSgJDxSqzo5HPbx7OgGpFeuvxq8/7nNO8mT3cEZfZASUGvjIdCRmAf243qyfo9Yby4zdx9E/BxNOGCZ9cQ==
dependencies:
"@babel/runtime" "^7.6.2"
"@react-aria/i18n" "^3.3.2"
"@react-aria/live-announcer" "^3.0.1"
"@react-aria/utils" "^3.8.2"
"@react-types/button" "^3.4.1"
"@react-types/shared" "^3.8.0"
"@react-aria/ssr@^3.0.3", "@react-aria/ssr@^3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@react-aria/ssr/-/ssr-3.1.0.tgz#b7163e6224725c30121932a8d1422ef91d1fab22"
@ -3140,6 +3179,16 @@
"@react-types/shared" "^3.9.0"
clsx "^1.1.1"
"@react-aria/visually-hidden@^3.2.3":
version "3.2.3"
resolved "https://registry.yarnpkg.com/@react-aria/visually-hidden/-/visually-hidden-3.2.3.tgz#4779df0a468873550afb42a7f5fcb2411d82db8d"
integrity sha512-iAe5EFI7obEOwTnIdAwWrKq+CrIJFGTw85v8fXnQ7CIVGRDblX85GOUww9bzQNPDLLRYWS4VF702ii8kV4+JCw==
dependencies:
"@babel/runtime" "^7.6.2"
"@react-aria/interactions" "^3.5.1"
"@react-aria/utils" "^3.8.2"
clsx "^1.1.1"
"@react-pdf/fontkit@^1.11.0", "@react-pdf/fontkit@^1.13.0":
version "1.13.0"
resolved "https://registry.yarnpkg.com/@react-pdf/fontkit/-/fontkit-1.13.0.tgz#ec14cc61120e814c1d48cf276771684ec615be9e"
@ -3207,6 +3256,17 @@
dependencies:
unicode-trie "^0.3.0"
"@react-stately/numberfield@^3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@react-stately/numberfield/-/numberfield-3.0.2.tgz#2e2831e60cafb7cc4b3124fe5f135a7bbd70e590"
integrity sha512-hxJt/Bj9cqJ8EPp9Vb0BL2CMWaRROWvxveiy76zcMMAT1TN33Wjhta+r+RjhJeUqDCHyvgcbYUeyxEbqrcipRA==
dependencies:
"@babel/runtime" "^7.6.2"
"@internationalized/number" "^3.0.2"
"@react-stately/utils" "^3.2.2"
"@react-types/numberfield" "^3.0.1"
"@react-types/shared" "^3.8.0"
"@react-stately/searchfield@^3.1.3":
version "3.1.3"
resolved "https://registry.yarnpkg.com/@react-stately/searchfield/-/searchfield-3.1.3.tgz#c2fe18be4ca8478c3bb3fdebc7e9e4a14ebfae07"
@ -3255,6 +3315,13 @@
dependencies:
"@react-types/shared" "^3.9.0"
"@react-types/numberfield@^3.0.1", "@react-types/numberfield@^3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@react-types/numberfield/-/numberfield-3.1.0.tgz#30aff4352a24797a235a74e538a8dd86c5c60af3"
integrity sha512-+QfvGqWD/QWOIyOCRDX/KyyV6QWdA/BQZKVpkFd0Vyy11GGT0eiKGyBevlN22/mwQkHbu53smVrRKXlHdB1tUQ==
dependencies:
"@react-types/shared" "^3.9.0"
"@react-types/searchfield@^3.1.2":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@react-types/searchfield/-/searchfield-3.1.2.tgz#184770b67f1fad57a6024ad00b7e42cf934ed54b"
@ -13852,13 +13919,6 @@ react-monaco-editor@^0.40.0:
monaco-editor "*"
prop-types "^15.7.2"
react-number-format@^4.3.1:
version "4.4.1"
resolved "https://registry.yarnpkg.com/react-number-format/-/react-number-format-4.4.1.tgz#d5614dd25edfc21ed48b97356213440081437a94"
integrity sha512-ZGFMXZ0U7DcmQ3bSZY3FULOA1mfqreT9NIMYZNoa/ouiSgiTQiYA95Uj2KN8ge6BRr+ghA5vraozqWqsHZQw3Q==
dependencies:
prop-types "^15.7.2"
react-reconciler@^0.24.0:
version "0.24.0"
resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.24.0.tgz#5a396b2c2f5efe8554134a5935f49f546723f2dd"