From 1a36518f612749561ce068da5fdc7218da1c314c Mon Sep 17 00:00:00 2001 From: Johan Girod Date: Tue, 19 Oct 2021 17:38:19 +0200 Subject: [PATCH] AJoute un champs de type Number (work in progress) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature : - Supporte les unités - Supporte les valeurs au clavier (haut / bas) - Supporte le formatting --- mon-entreprise/package.json | 5 +- .../CurrencyInput/CurrencyInput.css | 29 --- .../CurrencyInput/CurrencyInput.test.js | 167 ------------------ .../CurrencyInput/CurrencyInput.tsx | 108 ----------- .../source/components/PercentageField.css | 97 ---------- .../source/components/PercentageField.tsx | 48 ----- .../source/components/SimulationGoals.tsx | 3 + .../source/components/TargetSelection.css | 4 - .../source/components/TargetSelection.tsx | 19 +- .../source/components/conversation/Input.tsx | 62 ------- .../components/conversation/NumberInput.tsx | 139 +++++++++++++++ .../components/conversation/RuleInput.tsx | 37 +--- mon-entreprise/source/components/ui/reset.css | 10 -- .../design-system/field/NumberField.tsx | 133 ++++++++++++++ .../design-system/field/SearchField.tsx | 7 +- .../source/design-system/field/TextField.tsx | 59 +++++-- .../source/design-system/field/index.ts | 1 + mon-entreprise/source/entry.en.tsx | 8 +- mon-entreprise/source/entry.fr.tsx | 9 +- yarn.lock | 76 +++++++- 20 files changed, 419 insertions(+), 602 deletions(-) delete mode 100644 mon-entreprise/source/components/CurrencyInput/CurrencyInput.css delete mode 100644 mon-entreprise/source/components/CurrencyInput/CurrencyInput.test.js delete mode 100644 mon-entreprise/source/components/CurrencyInput/CurrencyInput.tsx delete mode 100644 mon-entreprise/source/components/PercentageField.css delete mode 100644 mon-entreprise/source/components/PercentageField.tsx delete mode 100644 mon-entreprise/source/components/conversation/Input.tsx create mode 100644 mon-entreprise/source/components/conversation/NumberInput.tsx create mode 100644 mon-entreprise/source/design-system/field/NumberField.tsx diff --git a/mon-entreprise/package.json b/mon-entreprise/package.json index f2b26862a..89fd98f96 100644 --- a/mon-entreprise/package.json +++ b/mon-entreprise/package.json @@ -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", diff --git a/mon-entreprise/source/components/CurrencyInput/CurrencyInput.css b/mon-entreprise/source/components/CurrencyInput/CurrencyInput.css deleted file mode 100644 index 253a288f6..000000000 --- a/mon-entreprise/source/components/CurrencyInput/CurrencyInput.css +++ /dev/null @@ -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; -} diff --git a/mon-entreprise/source/components/CurrencyInput/CurrencyInput.test.js b/mon-entreprise/source/components/CurrencyInput/CurrencyInput.test.js deleted file mode 100644 index 82d13f662..000000000 --- a/mon-entreprise/source/components/CurrencyInput/CurrencyInput.test.js +++ /dev/null @@ -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()).to.have.length(1) - }) - - it('should accept , as decimal separator in french', () => { - const onChange = spy() - const input = getInput() - 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( - - ) - const input2 = getInput( - - ) - const input3 = getInput( - - ) - const input4 = getInput( - - ) - 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() - expect(input.instance().value).to.equal('0,5') - }) - - it('should accept negative number', () => { - let onChange = spy() - const input = getInput() - 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() - 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() - 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( - - ) - 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() - expect(inputFr.children().last().text()).to.includes('€') - const inputEn = shallow() - expect(inputEn.children().first().text()).to.includes('€') - }) - - it('should debounce onChange call', () => { - const clock = useFakeTimers() - let onChange = spy() - const input = getInput( - - ) - 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() - expect(input.instance().value).to.equal('1') - }) - - it('should update its value if the value prop changes', () => { - const component = mount() - 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( - - ) - 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() - // 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() - .find('input') - .simulate('change', { - target: { value: '-', focus: () => {} }, - }) - mount() - .find('input') - .simulate('change', { - target: { value: ',', focus: () => {} }, - }) - mount() - .find('input') - .simulate('change', { - target: { value: ',5', focus: () => {} }, - }) - mount() - .find('input') - .simulate('change', { - target: { value: '8,', focus: () => {} }, - }) - expect(onChange).not.to.have.been.called - }) -}) diff --git a/mon-entreprise/source/components/CurrencyInput/CurrencyInput.tsx b/mon-entreprise/source/components/CurrencyInput/CurrencyInput.tsx deleted file mode 100644 index e7159bd21..000000000 --- a/mon-entreprise/source/components/CurrencyInput/CurrencyInput.tsx +++ /dev/null @@ -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) => 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 component doesn't provide - // the DOM `event` in its custom `onValueChange` handler - const nextValue = useRef('') - - const inputRef = useRef() - - // 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) => { - // 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 ( -
5 ? { width } : {}), ...style }} - onFocus={() => inputRef.current?.select()} - onClick={() => inputRef.current?.focus()} - > - {isCurrencyPrefixed && currentValue == '' && <>€ } - - { - setCurrentValue(value) - nextValue.current = value - .toString() - .replace(/^0+(.*)$/, '$1') - .replace(/^$/, '0') - }} - onChange={handleChange} - value={currentValue != null ? currentValue : ''} - autoComplete="off" - /> - {!isCurrencyPrefixed && <> €} -
- ) -} diff --git a/mon-entreprise/source/components/PercentageField.css b/mon-entreprise/source/components/PercentageField.css deleted file mode 100644 index e51a19e08..000000000 --- a/mon-entreprise/source/components/PercentageField.css +++ /dev/null @@ -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; -} diff --git a/mon-entreprise/source/components/PercentageField.tsx b/mon-entreprise/source/components/PercentageField.tsx deleted file mode 100644 index c50ff386d..000000000 --- a/mon-entreprise/source/components/PercentageField.tsx +++ /dev/null @@ -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 ( -
- { - const value = e.target.value - setLocalValue(+value) - debouncedOnChange(value) - }} - type="range" - value={localValue} - name="volume" - min="0" - step="0.05" - max="1" - /> - - {formatValue(localValue, { - language, - displayedUnit: '%', - })} - -
- ) -} diff --git a/mon-entreprise/source/components/SimulationGoals.tsx b/mon-entreprise/source/components/SimulationGoals.tsx index d3a7fc3a1..b5a0a867d 100644 --- a/mon-entreprise/source/components/SimulationGoals.tsx +++ b/mon-entreprise/source/components/SimulationGoals.tsx @@ -155,6 +155,9 @@ export function SimulationGoal({ onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} onChange={onChange} + formatOptions={{ + maximumFractionDigits: 0, + }} useSwitch /> ) : ( diff --git a/mon-entreprise/source/components/TargetSelection.css b/mon-entreprise/source/components/TargetSelection.css index 3c4eeac66..4144e194a 100644 --- a/mon-entreprise/source/components/TargetSelection.css +++ b/mon-entreprise/source/components/TargetSelection.css @@ -118,10 +118,6 @@ text-decoration: none; } -#targetSelection input { - margin: 2.7px 0; -} - #targetSelection .targetInput { width: 5.5em; max-width: 7.5em; diff --git a/mon-entreprise/source/components/TargetSelection.tsx b/mon-entreprise/source/components/TargetSelection.tsx index 340994fec..16dd2eee9 100644 --- a/mon-entreprise/source/components/TargetSelection.tsx +++ b/mon-entreprise/source/components/TargetSelection.tsx @@ -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 && } - { setFocused(true) diff --git a/mon-entreprise/source/components/conversation/Input.tsx b/mon-entreprise/source/components/conversation/Input.tsx deleted file mode 100644 index 3194abac8..000000000 --- a/mon-entreprise/source/components/conversation/Input.tsx +++ /dev/null @@ -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 ( -
-
- { - onChange(value) - }} - onSecondClick={() => onSubmit?.('suggestion')} - /> - { - if (floatValue !== value) { - debouncedOnChange( - floatValue != undefined ? { valeur: floatValue, unité } : {} - ) - } - }} - autoComplete="off" - {...{ [missing ? 'placeholder' : 'value']: value ?? '' }} - /> -  {unité} -
-
- ) -} diff --git a/mon-entreprise/source/components/conversation/NumberInput.tsx b/mon-entreprise/source/components/conversation/NumberInput.tsx new file mode 100644 index 000000000..5f9e5899c --- /dev/null +++ b/mon-entreprise/source/components/conversation/NumberInput.tsx @@ -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( + !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 ( +
+
+ { + handleChange(value) + setImmediate(() => { + onChange(value) + }) + }} + onSecondClick={() => onSubmit?.('suggestion')} + /> + { + handleChange(valeur) + if (!Number.isNaN(valeur)) { + debouncedOnChange({ valeur, unité }) + } + }} + formatOptions={formatOptions} + placeholder={missing && value != null ? value : undefined} + value={currentValue} + /> +
+
+ ) +} + +// 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 +} diff --git a/mon-entreprise/source/components/conversation/RuleInput.tsx b/mon-entreprise/source/components/conversation/RuleInput.tsx index b3cfa5286..013a04d90 100644 --- a/mon-entreprise/source/components/conversation/RuleInput.tsx +++ b/mon-entreprise/source/components/conversation/RuleInput.tsx @@ -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 = Omit< isTarget?: boolean onSubmit?: (source: string) => void modifiers?: Record + formatOptions: Intl.NumberFormatOptions } export type InputProps = 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, @@ -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 ( - <> - {}} - onChange={(evt) => - commonProps.onChange({ valeur: evt.target.value, unité }) - } - value={value as number} - /> - - ) - } - if (evaluation.unit?.numerators.includes('%') && isTarget) { - return - } if (rule.rawNode.type === 'texte') { return } /> } @@ -192,7 +163,7 @@ export default function RuleInput({ } return ( - | 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(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 ( + + + )} + placeholder={props.placeholder ?? ''} + ref={ref} + /> + {props.displayedUnit && ( + +  {props.displayedUnit} + + )} + + {props.label && ( + {props.label} + )} + + {props.errorMessage && ( + + {props.errorMessage} + + )} + {props.description && ( + + {props.description} + + )} + + ) +} + +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; +` diff --git a/mon-entreprise/source/design-system/field/SearchField.tsx b/mon-entreprise/source/design-system/field/SearchField.tsx index 5503f8473..61d60c889 100644 --- a/mon-entreprise/source/design-system/field/SearchField.tsx +++ b/mon-entreprise/source/design-system/field/SearchField.tsx @@ -26,14 +26,17 @@ export default function SearchField(props: AriaSearchFieldProps) { return ( )} placeholder={inputProps.placeholder ?? ''} ref={ref} /> - {props.label} + {props.label && ( + {props.label} + )} {state.value !== '' && ( × )} diff --git a/mon-entreprise/source/design-system/field/TextField.tsx b/mon-entreprise/source/design-system/field/TextField.tsx index ce150527e..a3f50b256 100644 --- a/mon-entreprise/source/design-system/field/TextField.tsx +++ b/mon-entreprise/source/design-system/field/TextField.tsx @@ -12,14 +12,17 @@ export default function TextField(props: AriaTextFieldOptions) { return ( )} placeholder={inputProps.placeholder ?? ''} ref={ref} /> - {props.label} + {props.label && ( + {props.label} + )} {props.errorMessage && ( @@ -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}; + } ` diff --git a/mon-entreprise/source/design-system/field/index.ts b/mon-entreprise/source/design-system/field/index.ts index edb0e898e..2c8e23976 100644 --- a/mon-entreprise/source/design-system/field/index.ts +++ b/mon-entreprise/source/design-system/field/index.ts @@ -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' diff --git a/mon-entreprise/source/entry.en.tsx b/mon-entreprise/source/entry.en.tsx index 209e25c7e..ccdbf4604 100644 --- a/mon-entreprise/source/entry.en.tsx +++ b/mon-entreprise/source/entry.en.tsx @@ -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(, anchor) +render( + + + , + anchor +) diff --git a/mon-entreprise/source/entry.fr.tsx b/mon-entreprise/source/entry.fr.tsx index 46ee989c2..5bbf45205 100644 --- a/mon-entreprise/source/entry.fr.tsx +++ b/mon-entreprise/source/entry.fr.tsx @@ -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(() => ) const anchor = document.querySelector('#js') -render(, anchor) +render( + + + , + anchor +) diff --git a/yarn.lock b/yarn.lock index 98fe7be6b..33699ba93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"