From 1f6be16e084181533fb28591a078cd67bbba5daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20P=C3=A4ttikangas?= Date: Tue, 29 Aug 2023 17:30:34 +0300 Subject: [PATCH 01/25] WIP: TimeInput --- .styleguidist/styleguidist.sections.js | 1 + .../Form/TimeInput/TimeInput.baseStyles.tsx | 113 ++++++++++++ src/core/Form/TimeInput/TimeInput.md | 16 ++ src/core/Form/TimeInput/TimeInput.tsx | 173 ++++++++++++++++++ src/core/Form/index.ts | 1 + src/index.tsx | 2 + 6 files changed, 306 insertions(+) create mode 100644 src/core/Form/TimeInput/TimeInput.baseStyles.tsx create mode 100644 src/core/Form/TimeInput/TimeInput.md create mode 100644 src/core/Form/TimeInput/TimeInput.tsx diff --git a/.styleguidist/styleguidist.sections.js b/.styleguidist/styleguidist.sections.js index 4f6351489..87fad3519 100644 --- a/.styleguidist/styleguidist.sections.js +++ b/.styleguidist/styleguidist.sections.js @@ -16,6 +16,7 @@ const singularComponents = [ 'Notification', 'Toast', ['Form', 'TextInput'], + ['Form', 'TimeInput'], ['Form', 'SearchInput'], ['Form', 'Textarea'], ['Form', 'DateInput'], diff --git a/src/core/Form/TimeInput/TimeInput.baseStyles.tsx b/src/core/Form/TimeInput/TimeInput.baseStyles.tsx new file mode 100644 index 000000000..bc773adeb --- /dev/null +++ b/src/core/Form/TimeInput/TimeInput.baseStyles.tsx @@ -0,0 +1,113 @@ +import { css } from 'styled-components'; +import { SuomifiTheme } from '../../theme'; +import { font, element } from '../../theme/reset'; + +export const baseStyles = (theme: SuomifiTheme) => css` + ${font(theme)('bodyText')}; + display: inline-block; + + .fi-time-input_wrapper { + width: 100%; + display: inline-block; + + .fi-time-input_label--visible { + margin-bottom: ${theme.spacing.xs}; + } + + .fi-hint-text { + margin-bottom: ${theme.spacing.xs}; + } + + .fi-time-input_statusText--has-content { + margin-top: ${theme.spacing.xxs}; + } + + .fi-time-input_input-container { + ${element(theme)} + ${font(theme)('actionElementInnerText')} + width: 95px; + height: 40px; + border: 1px solid ${theme.colors.depthLight1}; + border-radius: ${theme.radiuses.basic}; + line-height: 1; + display: flex; + align-items: center; + + .fi-time-input_hours-input-element-container { + &:focus-within { + position: relative; + ${theme.focuses.highContrastFocus} + + &::after { + /* Forked version of theme.focuses.absoluteFocus */ + content: ''; + position: absolute; + pointer-events: none; + top: -6px; + right: 2px; + bottom: -6px; + left: -2px; + border-radius: ${theme.radiuses.focus}; + background-color: transparent; + border: 0px solid ${theme.colors.whiteBase}; + box-sizing: border-box; + box-shadow: 0 0 0 2px ${theme.colors.accentSecondary}; + z-index: ${theme.zindexes.focus}; + } + } + } + + & .fi-time-input_minutes-input-element-container { + &:focus-within { + position: relative; + ${theme.focuses.highContrastFocus} + + &::after { + /* Forked version of theme.focuses.absoluteFocus */ + content: ''; + position: absolute; + pointer-events: none; + top: -6px; + right: -2px; + bottom: -6px; + left: 2px; + border-radius: ${theme.radiuses.focus}; + background-color: transparent; + border: 0px solid ${theme.colors.whiteBase}; + box-sizing: border-box; + box-shadow: 0 0 0 2px ${theme.colors.accentSecondary}; + z-index: ${theme.zindexes.focus}; + } + } + } + + .fi-time-input_hours-input, + .fi-time-input_minutes-input { + width: 45px; + padding: ${theme.spacing.insetM} ${theme.spacing.insetL}; + text-align: center; + + &::placeholder { + color: ${theme.colors.depthDark2}; + opacity: 1; + } + + &:focus { + outline: none; + &::placeholder { + opacity: 0; + } + } + } + + .fi-time-input_dot-separator { + width: 5px; + font-size: 18px; + padding: ${theme.spacing.insetM} 0; + display: flex; + justify-content: center; + align-items: flex-end; + } + } + } +`; diff --git a/src/core/Form/TimeInput/TimeInput.md b/src/core/Form/TimeInput/TimeInput.md new file mode 100644 index 000000000..dbf78edbf --- /dev/null +++ b/src/core/Form/TimeInput/TimeInput.md @@ -0,0 +1,16 @@ +The `` component is used to input times. It consists of two inputs (hours and minutes) which are labelled and visually presented together as one input. + +TimeInput is typically used together with [DateInput](/#/Components/DateInput). + +### Basic use + +```jsx +import { TimeInput } from 'suomifi-ui-components'; + +; +``` + +### Props & methods diff --git a/src/core/Form/TimeInput/TimeInput.tsx b/src/core/Form/TimeInput/TimeInput.tsx new file mode 100644 index 000000000..dd3071669 --- /dev/null +++ b/src/core/Form/TimeInput/TimeInput.tsx @@ -0,0 +1,173 @@ +import React, { ReactNode, ReactElement, forwardRef, FocusEvent } from 'react'; +import { SuomifiThemeProp, SuomifiThemeConsumer } from '../../theme'; +import { AutoId } from '../../utils/AutoId/AutoId'; +import { HtmlDiv, HtmlInput, HtmlSpan } from '../../../reset'; +import styled from 'styled-components'; +import { HTMLAttributesIncludingDataAttributes } from '../../../utils/common/common'; +import { Label, LabelMode } from '../Label/Label'; +import { baseStyles } from './TimeInput.baseStyles'; +import { StatusTextCommonProps, InputStatus } from '../types'; +import classnames from 'classnames'; +import { HintText } from '../HintText/HintText'; + +export interface TimeInputProps + extends StatusTextCommonProps, + Omit< + HTMLAttributesIncludingDataAttributes, + 'className' | 'onChange' + > { + /** CSS class for custom styles */ + className?: string; + /** Disables the input */ + disabled?: boolean; + /** Callback fired on input click */ + onClick?: () => void; + /** Callback fired on input change */ + onChange?: (value: string) => void; + /** Callback fired on input blur */ + onBlur?: (event: FocusEvent) => void; + /** Label for the input */ + labelText: ReactNode; + /** Hides or shows the label. Label element is always present, but can be visually hidden. + * @default visible + */ + labelMode?: LabelMode; + /** Hint text to be shown below the component */ + hintText?: string; + /** + * `'default'` | `'error'` + * + * Status of the component. Error state creates a red border around the Checkbox. + * Always use a descriptive `statusText` with an error status. + * @default default + */ + status?: InputStatus; + /** HTML name attribute for the input */ + name?: string; + /** Controlled value */ + value?: string; + /** Text to mark the field optional. Will be wrapped in parentheses and shown after `labelText` */ + optionalText?: string; + /** Ref is placed to the outermost div element of the component. Alternative for React `ref` attribute. */ + forwardedRef?: React.Ref; + /** Tooltip component for the input's label */ + tooltipComponent?: ReactElement; +} + +const baseClassName = 'fi-time-input'; +const timeInputClassNames = { + baseClassName, + disabled: `${baseClassName}--disabled`, + error: `${baseClassName}--error`, + success: `${baseClassName}--success`, + labelIsVisible: `${baseClassName}_label--visible`, + inputContainer: `${baseClassName}_input-container`, + hoursInputElementContainer: `${baseClassName}_hours-input-element-container`, + minutesInputElementContainer: `${baseClassName}_minutes-input-element-container`, + styleWrapper: `${baseClassName}_wrapper`, + statusTextHasContent: `${baseClassName}_statusText--has-content`, + hoursInput: `${baseClassName}_hours-input`, + minutesInput: `${baseClassName}_minutes-input`, + dotSeparator: `${baseClassName}_dot-separator`, +}; + +const BaseTimeInput = (props: TimeInputProps) => { + const { + className, + labelText, + labelMode, + onChange: propOnChange, + optionalText, + status, + statusText, + hintText, + id, + disabled = false, + forwardedRef, + statusTextAriaLiveMode = 'assertive', + 'aria-describedby': ariaDescribedBy, + tooltipComponent, + ...passProps + } = props; + + const hintTextId = `${id}-hintText`; + // const statusTextId = `${id}-statusText`; + + return ( + + + + {hintText} + + + + + . + + + + + + + ); +}; + +const StyledTimeInput = styled( + ({ theme, ...passProps }: TimeInputProps & SuomifiThemeProp) => ( + + ), +)` + ${({ theme }) => baseStyles(theme)} +`; + +const TimeInput = forwardRef( + (props: TimeInputProps, ref: React.Ref) => { + const { id: propId, ...passProps } = props; + return ( + + {({ suomifiTheme }) => ( + + {(id) => ( + + )} + + )} + + ); + }, +); + +TimeInput.displayName = 'TimeInput'; +export { TimeInput }; diff --git a/src/core/Form/index.ts b/src/core/Form/index.ts index 6852a4f7b..2110197c6 100644 --- a/src/core/Form/index.ts +++ b/src/core/Form/index.ts @@ -5,6 +5,7 @@ export { ToggleButtonProps, } from './Toggle'; export { TextInput, TextInputProps } from './TextInput/TextInput'; +export { TimeInput, TimeInputProps } from './TimeInput/TimeInput'; export { SearchInput, SearchInputProps } from './SearchInput/SearchInput'; export { Checkbox, CheckboxProps } from './Checkbox/Checkbox'; export { CheckboxGroup, CheckboxGroupProps } from './Checkbox/CheckboxGroup'; diff --git a/src/index.tsx b/src/index.tsx index d77bf30ca..6f1650d41 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -37,6 +37,8 @@ export { CheckboxGroupProps, TextInput, TextInputProps, + TimeInput, + TimeInputProps, ToggleInput, ToggleInputProps, ToggleButton, From 251c2f811f3111b0d719bd571a864453ec4c3fb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20P=C3=A4ttikangas?= Date: Thu, 31 Aug 2023 14:37:18 +0300 Subject: [PATCH 02/25] TimeInput: Add onChange, onKeyDown and onBlur handlers --- .../Form/TimeInput/TimeInput.baseStyles.tsx | 43 ++- src/core/Form/TimeInput/TimeInput.md | 40 ++- src/core/Form/TimeInput/TimeInput.tsx | 245 +++++++++++++++++- 3 files changed, 317 insertions(+), 11 deletions(-) diff --git a/src/core/Form/TimeInput/TimeInput.baseStyles.tsx b/src/core/Form/TimeInput/TimeInput.baseStyles.tsx index bc773adeb..7a4798e6c 100644 --- a/src/core/Form/TimeInput/TimeInput.baseStyles.tsx +++ b/src/core/Form/TimeInput/TimeInput.baseStyles.tsx @@ -6,6 +6,8 @@ export const baseStyles = (theme: SuomifiTheme) => css` ${font(theme)('bodyText')}; display: inline-block; + /* stylelint-disable no-descending-specificity */ + .fi-time-input_wrapper { width: 100%; display: inline-block; @@ -25,7 +27,7 @@ export const baseStyles = (theme: SuomifiTheme) => css` .fi-time-input_input-container { ${element(theme)} ${font(theme)('actionElementInnerText')} - width: 95px; + width: 97px; height: 40px; border: 1px solid ${theme.colors.depthLight1}; border-radius: ${theme.radiuses.basic}; @@ -68,7 +70,7 @@ export const baseStyles = (theme: SuomifiTheme) => css` position: absolute; pointer-events: none; top: -6px; - right: -2px; + right: -1px; bottom: -6px; left: 2px; border-radius: ${theme.radiuses.focus}; @@ -81,6 +83,10 @@ export const baseStyles = (theme: SuomifiTheme) => css` } } + .fi-time-input_minutes-input { + padding-right: 13px; + } + .fi-time-input_hours-input, .fi-time-input_minutes-input { width: 45px; @@ -108,6 +114,39 @@ export const baseStyles = (theme: SuomifiTheme) => css` justify-content: center; align-items: flex-end; } + + .fi-time-input_hidden-input { + height: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; + position: absolute; + width: 0; + } + } + } + + &.fi-time-input--error { + & .fi-time-input_input-container { + border: 2px solid ${theme.colors.alertBase}; + } + } + &.fi-time-input--success { + & .fi-time-input_input-container { + border: 2px solid ${theme.colors.successBase}; + } + } + &.fi-time-input--disabled { + & .fi-time-input_input-container { + background-color: ${theme.colors.depthLight3}; + color: ${theme.colors.depthBase}; + + .fi-time-input_hours-input, + .fi-time-input_minutes-input { + &::placeholder { + color: ${theme.colors.depthBase}; + } + } } } `; diff --git a/src/core/Form/TimeInput/TimeInput.md b/src/core/Form/TimeInput/TimeInput.md index dbf78edbf..7f9c013a0 100644 --- a/src/core/Form/TimeInput/TimeInput.md +++ b/src/core/Form/TimeInput/TimeInput.md @@ -2,14 +2,50 @@ The `` component is used to input times. It consists of two inputs (h TimeInput is typically used together with [DateInput](/#/Components/DateInput). +If only certain intervals are allowed as the time value (e.g. every half hour) consider using the [Dropdown](/#/Components/Dropdown) or [SingleSelect](/#/Components/SingleSelect) components instead. + ### Basic use +Provide a descriptive `labelText` for the input. + +Also provide the `ariaLabelHours` and `ariaLabelMinutes` props for the individual inputs to give better context for screen reader users. + +```jsx +import { TimeInput } from 'suomifi-ui-components'; + +; +``` + +### Hint text + +Use the `hinText` prop to provide instructions regarding the input. + +```jsx +import { TimeInput } from 'suomifi-ui-components'; + +; +``` + +### Validation + ```jsx import { TimeInput } from 'suomifi-ui-components'; console.log(newValue)} />; ``` diff --git a/src/core/Form/TimeInput/TimeInput.tsx b/src/core/Form/TimeInput/TimeInput.tsx index dd3071669..1c6572c9d 100644 --- a/src/core/Form/TimeInput/TimeInput.tsx +++ b/src/core/Form/TimeInput/TimeInput.tsx @@ -1,4 +1,12 @@ -import React, { ReactNode, ReactElement, forwardRef, FocusEvent } from 'react'; +import React, { + ReactNode, + ReactElement, + forwardRef, + KeyboardEvent, + useRef, + useState, + ChangeEvent, +} from 'react'; import { SuomifiThemeProp, SuomifiThemeConsumer } from '../../theme'; import { AutoId } from '../../utils/AutoId/AutoId'; import { HtmlDiv, HtmlInput, HtmlSpan } from '../../../reset'; @@ -9,6 +17,8 @@ import { baseStyles } from './TimeInput.baseStyles'; import { StatusTextCommonProps, InputStatus } from '../types'; import classnames from 'classnames'; import { HintText } from '../HintText/HintText'; +import { StatusText } from '../StatusText/StatusText'; +import { getConditionalAriaProp } from '../../../utils/aria'; export interface TimeInputProps extends StatusTextCommonProps, @@ -16,6 +26,11 @@ export interface TimeInputProps HTMLAttributesIncludingDataAttributes, 'className' | 'onChange' > { + /** Props passed to the outermost div element of the component */ + wrapperProps?: Omit< + HTMLAttributesIncludingDataAttributes, + 'className' + >; /** CSS class for custom styles */ className?: string; /** Disables the input */ @@ -24,14 +39,24 @@ export interface TimeInputProps onClick?: () => void; /** Callback fired on input change */ onChange?: (value: string) => void; - /** Callback fired on input blur */ - onBlur?: (event: FocusEvent) => void; + /** Callback fired on minutes input blur */ + onBlur?: () => void; /** Label for the input */ labelText: ReactNode; /** Hides or shows the label. Label element is always present, but can be visually hidden. * @default visible */ labelMode?: LabelMode; + /** + * Text attached to the hours input to provide additional context for assistive technology. + * Value should be a localised version of 'hours' + */ + ariaLabelHours: string; + /** + * Text attached to the minutes input to provide additional context for assistive technology. + * Value should be a localised version of 'minutes' + */ + ariaLabelMinutes: string; /** Hint text to be shown below the component */ hintText?: string; /** @@ -46,6 +71,8 @@ export interface TimeInputProps name?: string; /** Controlled value */ value?: string; + /** Default value for non controlled input */ + defaultValue?: string; /** Text to mark the field optional. Will be wrapped in parentheses and shown after `labelText` */ optionalText?: string; /** Ref is placed to the outermost div element of the component. Alternative for React `ref` attribute. */ @@ -68,15 +95,53 @@ const timeInputClassNames = { statusTextHasContent: `${baseClassName}_statusText--has-content`, hoursInput: `${baseClassName}_hours-input`, minutesInput: `${baseClassName}_minutes-input`, + hiddenInput: `${baseClassName}_hidden-input`, dotSeparator: `${baseClassName}_dot-separator`, }; +/** + * Pad a one-char string with a leading zero + */ +const zeroPad = (value: string) => { + if (value.length === 1) { + return `0${value}`; + } + return value; +}; + +const incrementNumber = ( + min: number, + max: number, + current: number, + modifier: number, +) => Math.max(Math.min(current + modifier, max), min); + +const getHourAndMinuteValues = (value?: string): string[] | null => { + const valueString = `${value}`; + if (value && valueString.length > 0) { + if (valueString.match(/^\d{2}:\d{2}$/)) { + return valueString.split(':'); + } + } + return null; +}; + +const isShortNumericString = (inputValue: string): boolean => + inputValue.match(/^(\d{1,2})?$/) !== null; + const BaseTimeInput = (props: TimeInputProps) => { const { + wrapperProps, className, labelText, labelMode, + ariaLabelHours, + ariaLabelMinutes, + value, + defaultValue, onChange: propOnChange, + onClick: propOnClick, + onBlur: propOnBlur, optionalText, status, statusText, @@ -90,12 +155,121 @@ const BaseTimeInput = (props: TimeInputProps) => { ...passProps } = props; + const hoursInputRef = useRef(null); + const minutesInputRef = useRef(null); + const hiddenInputRef = useRef(null); + + const hoursAndMinutes: string[] | null = getHourAndMinuteValues( + defaultValue || value, + ); + const [hours, setHours] = useState( + hoursAndMinutes ? hoursAndMinutes[0] : '', + ); + const [minutes, setMinutes] = useState( + hoursAndMinutes ? hoursAndMinutes[1] : '', + ); + const [timeString, setTimeString] = useState( + hoursAndMinutes ? hoursAndMinutes.join(':') : '', + ); + + const handleHoursInputKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowRight' && !event.shiftKey) { + event.preventDefault(); + minutesInputRef.current?.focus(); + } + + console.log(hours); + + if ( + (event.key === 'ArrowUp' || event.key === 'ArrowDown') && + (isShortNumericString(hours) || hours === '') + ) { + event.preventDefault(); + const modifier = event.key === 'ArrowUp' ? 1 : -1; + const hoursAsInt = parseInt(hours, 10) || 0; + const newHours = String(incrementNumber(0, 23, hoursAsInt, modifier)); + updateTime(newHours, minutes); + } + }; + + const handleMinutesInputKeyDown = ( + event: KeyboardEvent, + ) => { + if (event.key === 'ArrowLeft' && !event.shiftKey) { + event.preventDefault(); + hoursInputRef.current?.focus(); + } + + if ( + (event.key === 'ArrowUp' || event.key === 'ArrowDown') && + (isShortNumericString(minutes) || minutes === '') + ) { + event.preventDefault(); + const modifier = event.key === 'ArrowUp' ? 1 : -1; + const minutesAsInt = parseInt(minutes, 10) || 0; + const newMinutes = zeroPad( + `${incrementNumber(0, 59, minutesAsInt, modifier)}`, + ); + updateTime(hours, newMinutes); + } + + if (event.key === 'Tab' && !event.shiftKey && !!propOnBlur) { + propOnBlur(); + } + }; + + const updateTime = (newHours: string, newMinutes: string) => { + setHours(newHours); + setMinutes(newMinutes); + const newTimeValue = + newHours.length === 0 && newMinutes.length === 0 + ? '' + : `${newHours}:${newMinutes}`; + setTimeString(newTimeValue); + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value', + )?.set; + if (nativeInputValueSetter) { + nativeInputValueSetter.call(hiddenInputRef.current, newTimeValue); + const event = new Event('input', { bubbles: true }); + hiddenInputRef.current?.dispatchEvent(event); + } + }; + + const onHoursChange: React.ChangeEventHandler = (event) => { + const hoursValue = event.target.value.slice(-2); + updateTime(hoursValue, minutes); + }; + + const onMinutesChange: React.ChangeEventHandler = ( + event, + ) => { + const minutesValue = event.target.value.slice(-2); + updateTime(hours, minutesValue); + }; + + const onHoursBlur: React.FocusEventHandler = () => { + if (hours === '') return; + if (hours.length > 1 && hours.startsWith('0')) { + updateTime(hours[1], minutes); + } else { + updateTime(hours, minutes); + } + }; + + const onMinutesBlur: React.FocusEventHandler = () => { + if (minutes === '') return; + updateTime(hours, zeroPad(minutes)); + }; + + const labelId = `${id}-mainLabel`; const hintTextId = `${id}-hintText`; - // const statusTextId = `${id}-statusText`; + const statusTextId = `${id}-statusText`; return ( { > + {hintText} - + ) => { + if (!!propOnChange) { + propOnChange(event.target.value); + } + }} + forwardedRef={hiddenInputRef} + value={timeString} + {...passProps} + /> + . + + {statusText} + ); From cf7c7dce4e3c8477bae3aed2eaeeaf5857ce0efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20P=C3=A4ttikangas?= Date: Thu, 31 Aug 2023 14:38:07 +0300 Subject: [PATCH 03/25] TimeInput: Remove an example from md file for now --- src/core/Form/TimeInput/TimeInput.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/core/Form/TimeInput/TimeInput.md b/src/core/Form/TimeInput/TimeInput.md index 7f9c013a0..b3053e78b 100644 --- a/src/core/Form/TimeInput/TimeInput.md +++ b/src/core/Form/TimeInput/TimeInput.md @@ -37,16 +37,4 @@ import { TimeInput } from 'suomifi-ui-components'; ### Validation -```jsx -import { TimeInput } from 'suomifi-ui-components'; - - console.log(newValue)} -/>; -``` - ### Props & methods From e5a2752df6e7819a7a8c57b6ad1af97f50790985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20P=C3=A4ttikangas?= Date: Mon, 4 Sep 2023 12:13:15 +0300 Subject: [PATCH 04/25] TImeInput: Change separator from : to . --- src/core/Form/TimeInput/TimeInput.md | 18 ++++++++++++++++++ src/core/Form/TimeInput/TimeInput.tsx | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/core/Form/TimeInput/TimeInput.md b/src/core/Form/TimeInput/TimeInput.md index b3053e78b..1bccfe7d6 100644 --- a/src/core/Form/TimeInput/TimeInput.md +++ b/src/core/Form/TimeInput/TimeInput.md @@ -35,6 +35,24 @@ import { TimeInput } from 'suomifi-ui-components'; />; ``` +### Default value + +Use the `defaultValue` prop to provide an initial value for the input. + +The value must be of format `H.mm` + +```jsx +import { TimeInput } from 'suomifi-ui-components'; + +; +``` + ### Validation ### Props & methods diff --git a/src/core/Form/TimeInput/TimeInput.tsx b/src/core/Form/TimeInput/TimeInput.tsx index 1c6572c9d..deebca80b 100644 --- a/src/core/Form/TimeInput/TimeInput.tsx +++ b/src/core/Form/TimeInput/TimeInput.tsx @@ -119,8 +119,8 @@ const incrementNumber = ( const getHourAndMinuteValues = (value?: string): string[] | null => { const valueString = `${value}`; if (value && valueString.length > 0) { - if (valueString.match(/^\d{2}:\d{2}$/)) { - return valueString.split(':'); + if (valueString.match(/^\d{2}.\d{2}$/)) { + return valueString.split('.'); } } return null; From 0f465b4c510bcdd4d12837efb5d16114658c1a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20P=C3=A4ttikangas?= Date: Thu, 14 Sep 2023 14:28:24 +0300 Subject: [PATCH 05/25] TimeInput: Change to one field format --- .../Form/TimeInput/TimeInput.baseStyles.tsx | 208 +++++----- src/core/Form/TimeInput/TimeInput.md | 33 +- src/core/Form/TimeInput/TimeInput.tsx | 374 ++++++------------ 3 files changed, 231 insertions(+), 384 deletions(-) diff --git a/src/core/Form/TimeInput/TimeInput.baseStyles.tsx b/src/core/Form/TimeInput/TimeInput.baseStyles.tsx index 7a4798e6c..a55cd1cbc 100644 --- a/src/core/Form/TimeInput/TimeInput.baseStyles.tsx +++ b/src/core/Form/TimeInput/TimeInput.baseStyles.tsx @@ -1,152 +1,136 @@ import { css } from 'styled-components'; import { SuomifiTheme } from '../../theme'; -import { font, element } from '../../theme/reset'; +import { input, containerIEFocus, font } from '../../theme/reset'; +import { math } from 'polished'; export const baseStyles = (theme: SuomifiTheme) => css` - ${font(theme)('bodyText')}; - display: inline-block; + ${font(theme)('bodyText')} + width: 290px; + + & .fi-time-input_character-counter { + ${font(theme)('bodyTextSmall')}; + color: ${theme.colors.blackBase}; + font-size: 14px; + line-height: 20px; + flex: none; + margin-top: 4px; + + &.fi-time-input_character-counter--error { + color: ${theme.colors.alertBase}; + ${font(theme)('bodySemiBoldSmall')}; + font-size: 14px; + line-height: 20px; + } + } - /* stylelint-disable no-descending-specificity */ + & .fi-time-input_bottom-wrapper { + display: flex; + justify-content: space-between; + } - .fi-time-input_wrapper { + & .fi-time-input_wrapper { width: 100%; display: inline-block; - .fi-time-input_label--visible { + & .fi-time-input_label--visible { margin-bottom: ${theme.spacing.xs}; } - .fi-hint-text { + & .fi-hint-text { margin-bottom: ${theme.spacing.xs}; } - .fi-time-input_statusText--has-content { + & .fi-time-input_statusText--has-content { margin-top: ${theme.spacing.xxs}; } + } - .fi-time-input_input-container { - ${element(theme)} - ${font(theme)('actionElementInnerText')} - width: 97px; - height: 40px; - border: 1px solid ${theme.colors.depthLight1}; - border-radius: ${theme.radiuses.basic}; - line-height: 1; - display: flex; - align-items: center; - - .fi-time-input_hours-input-element-container { - &:focus-within { - position: relative; - ${theme.focuses.highContrastFocus} - - &::after { - /* Forked version of theme.focuses.absoluteFocus */ - content: ''; - position: absolute; - pointer-events: none; - top: -6px; - right: 2px; - bottom: -6px; - left: -2px; - border-radius: ${theme.radiuses.focus}; - background-color: transparent; - border: 0px solid ${theme.colors.whiteBase}; - box-sizing: border-box; - box-shadow: 0 0 0 2px ${theme.colors.accentSecondary}; - z-index: ${theme.zindexes.focus}; - } - } - } + & .fi-time-input_input-element-container { + ${containerIEFocus(theme)} - & .fi-time-input_minutes-input-element-container { - &:focus-within { - position: relative; - ${theme.focuses.highContrastFocus} - - &::after { - /* Forked version of theme.focuses.absoluteFocus */ - content: ''; - position: absolute; - pointer-events: none; - top: -6px; - right: -1px; - bottom: -6px; - left: 2px; - border-radius: ${theme.radiuses.focus}; - background-color: transparent; - border: 0px solid ${theme.colors.whiteBase}; - box-sizing: border-box; - box-shadow: 0 0 0 2px ${theme.colors.accentSecondary}; - z-index: ${theme.zindexes.focus}; - } - } - } + &:focus-within { + position: relative; + ${theme.focuses.highContrastFocus} - .fi-time-input_minutes-input { - padding-right: 13px; + &::after { + ${theme.focuses.absoluteFocus} } + } + } - .fi-time-input_hours-input, - .fi-time-input_minutes-input { - width: 45px; - padding: ${theme.spacing.insetM} ${theme.spacing.insetL}; - text-align: center; - - &::placeholder { - color: ${theme.colors.depthDark2}; - opacity: 1; - } - - &:focus { - outline: none; - &::placeholder { - opacity: 0; - } - } - } + &.fi-time-input--full-width { + width: 100%; + } - .fi-time-input_dot-separator { - width: 5px; - font-size: 18px; - padding: ${theme.spacing.insetM} 0; - display: flex; - justify-content: center; - align-items: flex-end; - } + & .fi-time-input_input { + ${input(theme)} + background-color: ${theme.colors.whiteBase}; + min-width: 40px; + width: 100%; + min-height: 40px; + padding-left: ${theme.spacing.insetL}; + border-color: ${theme.colors.depthDark3}; + + &::placeholder { + font-style: italic; + color: ${theme.colors.depthDark2}; + opacity: 1; + } + &::-ms-clear { + display: none; + width: 0; + height: 0; + } + &::-ms-reveal { + display: none; + width: 0; + height: 0; + } + } - .fi-time-input_hidden-input { - height: 0; - opacity: 0; - overflow: hidden; - pointer-events: none; - position: absolute; - width: 0; - } + &.fi-time-input_with-icon { + & .fi-time-input_input-element-container { + position: relative; + } + + & .fi-time-input_input { + padding-right: ${math( + `${theme.spacing.insetXl} * 2 + ${theme.spacing.insetM}`, + )}; + } + + & .fi-icon { + position: absolute; + width: 18px; + height: 18px; + top: ${theme.spacing.insetL}; + right: ${theme.spacing.insetL}; } } &.fi-time-input--error { - & .fi-time-input_input-container { + & .fi-time-input_input { border: 2px solid ${theme.colors.alertBase}; + padding-left: 9px; + padding-top: 7px; + padding-bottom: 7px; } } &.fi-time-input--success { - & .fi-time-input_input-container { + & .fi-time-input_input { border: 2px solid ${theme.colors.successBase}; + padding-left: 9px; + padding-top: 7px; + padding-bottom: 7px; } } &.fi-time-input--disabled { - & .fi-time-input_input-container { - background-color: ${theme.colors.depthLight3}; + & .fi-time-input_input { color: ${theme.colors.depthBase}; - - .fi-time-input_hours-input, - .fi-time-input_minutes-input { - &::placeholder { - color: ${theme.colors.depthBase}; - } - } + background-color: ${theme.colors.depthLight3}; + } + & .fi-icon-base-fill { + fill: ${theme.colors.depthBase}; } } `; diff --git a/src/core/Form/TimeInput/TimeInput.md b/src/core/Form/TimeInput/TimeInput.md index 1bccfe7d6..7efa8d1f4 100644 --- a/src/core/Form/TimeInput/TimeInput.md +++ b/src/core/Form/TimeInput/TimeInput.md @@ -1,4 +1,18 @@ -The `` component is used to input times. It consists of two inputs (hours and minutes) which are labelled and visually presented together as one input. +The `` component is used to input times. It looks and behaves similarly to a regular `` but has the following autocomplete features: + +**On blur:** + +- If the user types 1 or 2 characters which are valid hours (0-24), the time will be autocompleted. A leading zero will also be removed. + - E.g. 14 --> 14.00 + - E.g. 09 --> 9.00 +- If the user types 4 characters which form a valid "military time" value, the dot `.` character will be added as a separator. + - E.g. 1400 --> 14.00 + +**On change:** + +- If the user types the colon `:` character, it will immediately get replaced with the dot `.` character. Dot is the correct time separator character in the Finnish language. + +-- TimeInput is typically used together with [DateInput](/#/Components/DateInput). @@ -8,16 +22,10 @@ If only certain intervals are allowed as the time value (e.g. every half hour) c Provide a descriptive `labelText` for the input. -Also provide the `ariaLabelHours` and `ariaLabelMinutes` props for the individual inputs to give better context for screen reader users. - ```jsx import { TimeInput } from 'suomifi-ui-components'; -; +; ``` ### Hint text @@ -28,10 +36,9 @@ Use the `hinText` prop to provide instructions regarding the input. import { TimeInput } from 'suomifi-ui-components'; console.log(event)} />; ``` @@ -46,8 +53,6 @@ import { TimeInput } from 'suomifi-ui-components'; ; diff --git a/src/core/Form/TimeInput/TimeInput.tsx b/src/core/Form/TimeInput/TimeInput.tsx index deebca80b..17be8947c 100644 --- a/src/core/Form/TimeInput/TimeInput.tsx +++ b/src/core/Form/TimeInput/TimeInput.tsx @@ -1,62 +1,70 @@ import React, { + forwardRef, + ChangeEvent, + FocusEvent, ReactNode, ReactElement, - forwardRef, - KeyboardEvent, useRef, useState, - ChangeEvent, } from 'react'; -import { SuomifiThemeProp, SuomifiThemeConsumer } from '../../theme'; +import { default as styled } from 'styled-components'; +import classnames from 'classnames'; import { AutoId } from '../../utils/AutoId/AutoId'; -import { HtmlDiv, HtmlInput, HtmlSpan } from '../../../reset'; -import styled from 'styled-components'; -import { HTMLAttributesIncludingDataAttributes } from '../../../utils/common/common'; +import { SuomifiThemeProp, SuomifiThemeConsumer } from '../../theme'; +import { Debounce } from '../../utils/Debounce/Debounce'; +import { getConditionalAriaProp } from '../../../utils/aria'; +import { + HTMLAttributesIncludingDataAttributes, + forkRefs, +} from '../../../utils/common/common'; +import { HtmlInputProps, HtmlDiv, HtmlSpan, HtmlInput } from '../../../reset'; import { Label, LabelMode } from '../Label/Label'; -import { baseStyles } from './TimeInput.baseStyles'; -import { StatusTextCommonProps, InputStatus } from '../types'; -import classnames from 'classnames'; -import { HintText } from '../HintText/HintText'; import { StatusText } from '../StatusText/StatusText'; -import { getConditionalAriaProp } from '../../../utils/aria'; +import { HintText } from '../HintText/HintText'; +import { InputStatus, StatusTextCommonProps } from '../types'; +import { baseStyles } from './TimeInput.baseStyles'; + +const baseClassName = 'fi-time-input'; +export const timeInputClassNames = { + baseClassName, + fullWidth: `${baseClassName}--full-width`, + disabled: `${baseClassName}--disabled`, + error: `${baseClassName}--error`, + success: `${baseClassName}--success`, + labelIsVisible: `${baseClassName}_label--visible`, + icon: `${baseClassName}_with-icon`, + inputElementContainer: `${baseClassName}_input-element-container`, + inputElement: `${baseClassName}_input`, + styleWrapper: `${baseClassName}_wrapper`, + statusTextHasContent: `${baseClassName}_statusText--has-content`, +}; export interface TimeInputProps extends StatusTextCommonProps, - Omit< - HTMLAttributesIncludingDataAttributes, - 'className' | 'onChange' - > { + Omit { + /** CSS class for custom styles */ + className?: string; /** Props passed to the outermost div element of the component */ wrapperProps?: Omit< HTMLAttributesIncludingDataAttributes, 'className' >; - /** CSS class for custom styles */ - className?: string; /** Disables the input */ disabled?: boolean; /** Callback fired on input click */ onClick?: () => void; /** Callback fired on input change */ onChange?: (value: string) => void; - /** Callback fired on minutes input blur */ - onBlur?: () => void; + /** Callback fired on input blur */ + onBlur?: (event: FocusEvent) => void; /** Label for the input */ labelText: ReactNode; /** Hides or shows the label. Label element is always present, but can be visually hidden. * @default visible */ labelMode?: LabelMode; - /** - * Text attached to the hours input to provide additional context for assistive technology. - * Value should be a localised version of 'hours' - */ - ariaLabelHours: string; - /** - * Text attached to the minutes input to provide additional context for assistive technology. - * Value should be a localised version of 'minutes' - */ - ariaLabelMinutes: string; + /** Placeholder text for the input. Use only as visual aid, not for instructions. */ + visualPlaceholder?: string; /** Hint text to be shown below the component */ hintText?: string; /** @@ -67,292 +75,142 @@ export interface TimeInputProps * @default default */ status?: InputStatus; + /** + * Type of the input + * @default text + */ /** HTML name attribute for the input */ name?: string; + /** Initial value for the input */ + defaultValue?: string; /** Controlled value */ value?: string; - /** Default value for non controlled input */ - defaultValue?: string; + /** Sets component's width to 100% of its parent */ + fullWidth?: boolean; /** Text to mark the field optional. Will be wrapped in parentheses and shown after `labelText` */ optionalText?: string; - /** Ref is placed to the outermost div element of the component. Alternative for React `ref` attribute. */ - forwardedRef?: React.Ref; + /** Debounce time in milliseconds for onChange function. No debounce is applied if no value is given. */ + debounce?: number; + /** Suomi.fi icon to be shown inside the input field */ + icon?: ReactElement; /** Tooltip component for the input's label */ tooltipComponent?: ReactElement; } -const baseClassName = 'fi-time-input'; -const timeInputClassNames = { - baseClassName, - disabled: `${baseClassName}--disabled`, - error: `${baseClassName}--error`, - success: `${baseClassName}--success`, - labelIsVisible: `${baseClassName}_label--visible`, - inputContainer: `${baseClassName}_input-container`, - hoursInputElementContainer: `${baseClassName}_hours-input-element-container`, - minutesInputElementContainer: `${baseClassName}_minutes-input-element-container`, - styleWrapper: `${baseClassName}_wrapper`, - statusTextHasContent: `${baseClassName}_statusText--has-content`, - hoursInput: `${baseClassName}_hours-input`, - minutesInput: `${baseClassName}_minutes-input`, - hiddenInput: `${baseClassName}_hidden-input`, - dotSeparator: `${baseClassName}_dot-separator`, -}; - -/** - * Pad a one-char string with a leading zero - */ -const zeroPad = (value: string) => { - if (value.length === 1) { - return `0${value}`; - } - return value; -}; - -const incrementNumber = ( - min: number, - max: number, - current: number, - modifier: number, -) => Math.max(Math.min(current + modifier, max), min); - -const getHourAndMinuteValues = (value?: string): string[] | null => { - const valueString = `${value}`; - if (value && valueString.length > 0) { - if (valueString.match(/^\d{2}.\d{2}$/)) { - return valueString.split('.'); - } - } - return null; -}; - -const isShortNumericString = (inputValue: string): boolean => - inputValue.match(/^(\d{1,2})?$/) !== null; - const BaseTimeInput = (props: TimeInputProps) => { const { - wrapperProps, className, labelText, labelMode, - ariaLabelHours, - ariaLabelMinutes, - value, - defaultValue, onChange: propOnChange, - onClick: propOnClick, onBlur: propOnBlur, + wrapperProps, optionalText, status, statusText, hintText, + visualPlaceholder, id, - disabled = false, + fullWidth, + icon, + value: controlledValue, + defaultValue, forwardedRef, + debounce, statusTextAriaLiveMode = 'assertive', 'aria-describedby': ariaDescribedBy, tooltipComponent, ...passProps } = props; - const hoursInputRef = useRef(null); - const minutesInputRef = useRef(null); - const hiddenInputRef = useRef(null); + const [inputValue, setInputValue] = useState(defaultValue || ''); - const hoursAndMinutes: string[] | null = getHourAndMinuteValues( - defaultValue || value, - ); - const [hours, setHours] = useState( - hoursAndMinutes ? hoursAndMinutes[0] : '', - ); - const [minutes, setMinutes] = useState( - hoursAndMinutes ? hoursAndMinutes[1] : '', - ); - const [timeString, setTimeString] = useState( - hoursAndMinutes ? hoursAndMinutes.join(':') : '', - ); - - const handleHoursInputKeyDown = (event: KeyboardEvent) => { - if (event.key === 'ArrowRight' && !event.shiftKey) { - event.preventDefault(); - minutesInputRef.current?.focus(); - } + const inputRef = useRef(null); + const definedRef = forwardedRef || null; - console.log(hours); + const handleOnBlur = (event: FocusEvent) => { + const inputValInt = parseInt(inputValue, 10); - if ( - (event.key === 'ArrowUp' || event.key === 'ArrowDown') && - (isShortNumericString(hours) || hours === '') - ) { - event.preventDefault(); - const modifier = event.key === 'ArrowUp' ? 1 : -1; - const hoursAsInt = parseInt(hours, 10) || 0; - const newHours = String(incrementNumber(0, 23, hoursAsInt, modifier)); - updateTime(newHours, minutes); + // Handle automatic filling of 1 or 2 characters: 14 --> 14.00. + // Also remove leading zero from hours which are under 10 + if (inputValue.length <= 2 && !Number.isNaN(inputValInt)) { + if (inputValInt >= 0 && inputValInt < 25) { + setInputValue(`${inputValInt}.00`); + } } - }; - const handleMinutesInputKeyDown = ( - event: KeyboardEvent, - ) => { - if (event.key === 'ArrowLeft' && !event.shiftKey) { - event.preventDefault(); - hoursInputRef.current?.focus(); + // Handle automatic filling of 4 characters: 1400 --> 14.00 + if (inputValue.length === 4 && !Number.isNaN(inputValInt)) { + if (inputValInt >= 0 && inputValInt < 2500) { + setInputValue( + `${inputValue[0]}${inputValue[1]}.${inputValue[2]}${inputValue[3]}`, + ); + } } - if ( - (event.key === 'ArrowUp' || event.key === 'ArrowDown') && - (isShortNumericString(minutes) || minutes === '') - ) { - event.preventDefault(); - const modifier = event.key === 'ArrowUp' ? 1 : -1; - const minutesAsInt = parseInt(minutes, 10) || 0; - const newMinutes = zeroPad( - `${incrementNumber(0, 59, minutesAsInt, modifier)}`, - ); - updateTime(hours, newMinutes); - } - - if (event.key === 'Tab' && !event.shiftKey && !!propOnBlur) { - propOnBlur(); - } - }; - - const updateTime = (newHours: string, newMinutes: string) => { - setHours(newHours); - setMinutes(newMinutes); - const newTimeValue = - newHours.length === 0 && newMinutes.length === 0 - ? '' - : `${newHours}:${newMinutes}`; - setTimeString(newTimeValue); - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - 'value', - )?.set; - if (nativeInputValueSetter) { - nativeInputValueSetter.call(hiddenInputRef.current, newTimeValue); - const event = new Event('input', { bubbles: true }); - hiddenInputRef.current?.dispatchEvent(event); - } - }; - - const onHoursChange: React.ChangeEventHandler = (event) => { - const hoursValue = event.target.value.slice(-2); - updateTime(hoursValue, minutes); - }; - - const onMinutesChange: React.ChangeEventHandler = ( - event, - ) => { - const minutesValue = event.target.value.slice(-2); - updateTime(hours, minutesValue); - }; - - const onHoursBlur: React.FocusEventHandler = () => { - if (hours === '') return; - if (hours.length > 1 && hours.startsWith('0')) { - updateTime(hours[1], minutes); - } else { - updateTime(hours, minutes); + if (!!propOnBlur) { + propOnBlur(event); } }; - const onMinutesBlur: React.FocusEventHandler = () => { - if (minutes === '') return; - updateTime(hours, zeroPad(minutes)); - }; - - const labelId = `${id}-mainLabel`; const hintTextId = `${id}-hintText`; const statusTextId = `${id}-statusText`; - return ( - {hintText} - - - ) => { - if (!!propOnChange) { - propOnChange(event.target.value); - } - }} - forwardedRef={hiddenInputRef} - value={timeString} - {...passProps} - /> - - - . - - - + + + {(debouncer: Function) => ( + ) => { + if (propOnChange) { + debouncer(propOnChange, event.currentTarget.value); + } + + if (event.currentTarget.value.includes(':')) { + setInputValue(event.currentTarget.value.replace(':', '.')); + } else { + setInputValue(event.currentTarget.value); + } + }} + onBlur={handleOnBlur} + value={controlledValue || inputValue} + /> + )} + + {icon} { })} status={status} ariaLiveMode={statusTextAriaLiveMode} - disabled={disabled} + disabled={passProps.disabled} > {statusText} @@ -378,8 +236,8 @@ const StyledTimeInput = styled( ${({ theme }) => baseStyles(theme)} `; -const TimeInput = forwardRef( - (props: TimeInputProps, ref: React.Ref) => { +const TimeInput = forwardRef( + (props: TimeInputProps, ref: React.Ref) => { const { id: propId, ...passProps } = props; return ( From fe8a8b319c1429a6d8e3a82a59f3e14c5c0c5425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20P=C3=A4ttikangas?= Date: Thu, 14 Sep 2023 14:46:54 +0300 Subject: [PATCH 06/25] TimeInput autocomplete and style fixes --- src/core/Form/TimeInput/TimeInput.baseStyles.tsx | 12 ++++++++---- src/core/Form/TimeInput/TimeInput.md | 10 ++++++++++ src/core/Form/TimeInput/TimeInput.tsx | 8 ++++++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/core/Form/TimeInput/TimeInput.baseStyles.tsx b/src/core/Form/TimeInput/TimeInput.baseStyles.tsx index a55cd1cbc..a444aa7fc 100644 --- a/src/core/Form/TimeInput/TimeInput.baseStyles.tsx +++ b/src/core/Form/TimeInput/TimeInput.baseStyles.tsx @@ -1,6 +1,6 @@ import { css } from 'styled-components'; import { SuomifiTheme } from '../../theme'; -import { input, containerIEFocus, font } from '../../theme/reset'; +import { element, containerIEFocus, font } from '../../theme/reset'; import { math } from 'polished'; export const baseStyles = (theme: SuomifiTheme) => css` @@ -46,6 +46,7 @@ export const baseStyles = (theme: SuomifiTheme) => css` } & .fi-time-input_input-element-container { + width: 60px; ${containerIEFocus(theme)} &:focus-within { @@ -63,10 +64,13 @@ export const baseStyles = (theme: SuomifiTheme) => css` } & .fi-time-input_input { - ${input(theme)} - background-color: ${theme.colors.whiteBase}; - min-width: 40px; + ${element(theme)} + ${font(theme)('actionElementInnerText')} width: 100%; + border: 1px solid ${theme.colors.depthLight1}; + border-radius: ${theme.radiuses.basic}; + line-height: 1; + background-color: ${theme.colors.whiteBase}; min-height: 40px; padding-left: ${theme.spacing.insetL}; border-color: ${theme.colors.depthDark3}; diff --git a/src/core/Form/TimeInput/TimeInput.md b/src/core/Form/TimeInput/TimeInput.md index 7efa8d1f4..17f3336aa 100644 --- a/src/core/Form/TimeInput/TimeInput.md +++ b/src/core/Form/TimeInput/TimeInput.md @@ -18,6 +18,16 @@ TimeInput is typically used together with [DateInput](/#/Components/DateInput). If only certain intervals are allowed as the time value (e.g. every half hour) consider using the [Dropdown](/#/Components/Dropdown) or [SingleSelect](/#/Components/SingleSelect) components instead. +Examples: + +- [Basic use](./#/Components/TimeInput?id=basic-use) +- [Hint text](./#/Components/TimeInput?id=hint-text) +- [Default value](./#/Components/TimeInput?id=default-value) + +
+ [Props & methods](./#/Components/TimeInput?id=props--methods) +
+ ### Basic use Provide a descriptive `labelText` for the input. diff --git a/src/core/Form/TimeInput/TimeInput.tsx b/src/core/Form/TimeInput/TimeInput.tsx index 17be8947c..cf7aaf54d 100644 --- a/src/core/Form/TimeInput/TimeInput.tsx +++ b/src/core/Form/TimeInput/TimeInput.tsx @@ -133,14 +133,18 @@ const BaseTimeInput = (props: TimeInputProps) => { // Handle automatic filling of 1 or 2 characters: 14 --> 14.00. // Also remove leading zero from hours which are under 10 - if (inputValue.length <= 2 && !Number.isNaN(inputValInt)) { + if (inputValue.length <= 2 && !Number.isNaN(inputValue)) { if (inputValInt >= 0 && inputValInt < 25) { setInputValue(`${inputValInt}.00`); } } // Handle automatic filling of 4 characters: 1400 --> 14.00 - if (inputValue.length === 4 && !Number.isNaN(inputValInt)) { + if ( + inputValue.length === 4 && + !Number.isNaN(inputValue) && + /^\d+$/.test(inputValue) + ) { if (inputValInt >= 0 && inputValInt < 2500) { setInputValue( `${inputValue[0]}${inputValue[1]}.${inputValue[2]}${inputValue[3]}`, From 272cc832a6f4532dee416b9690ef0e79b96016ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20P=C3=A4ttikangas?= Date: Wed, 27 Sep 2023 15:56:49 +0300 Subject: [PATCH 07/25] TimeInput: Add some more logic and styles --- .../Form/TimeInput/TimeInput.baseStyles.tsx | 4 ++-- src/core/Form/TimeInput/TimeInput.md | 20 +++++++------------ src/core/Form/TimeInput/TimeInput.tsx | 16 ++++++++------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/core/Form/TimeInput/TimeInput.baseStyles.tsx b/src/core/Form/TimeInput/TimeInput.baseStyles.tsx index a444aa7fc..680bb25cc 100644 --- a/src/core/Form/TimeInput/TimeInput.baseStyles.tsx +++ b/src/core/Form/TimeInput/TimeInput.baseStyles.tsx @@ -72,13 +72,13 @@ export const baseStyles = (theme: SuomifiTheme) => css` line-height: 1; background-color: ${theme.colors.whiteBase}; min-height: 40px; - padding-left: ${theme.spacing.insetL}; + padding: ${theme.spacing.insetL}; border-color: ${theme.colors.depthDark3}; &::placeholder { - font-style: italic; color: ${theme.colors.depthDark2}; opacity: 1; + text-align: center; } &::-ms-clear { display: none; diff --git a/src/core/Form/TimeInput/TimeInput.md b/src/core/Form/TimeInput/TimeInput.md index 17f3336aa..6c3a84b65 100644 --- a/src/core/Form/TimeInput/TimeInput.md +++ b/src/core/Form/TimeInput/TimeInput.md @@ -1,16 +1,11 @@ -The `` component is used to input times. It looks and behaves similarly to a regular `` but has the following autocomplete features: - -**On blur:** +The `` component is used to input times. It looks and behaves similarly to a regular `` but has the following autocomplete features on input blur: - If the user types 1 or 2 characters which are valid hours (0-24), the time will be autocompleted. A leading zero will also be removed. - E.g. 14 --> 14.00 - - E.g. 09 --> 9.00 + - E.g. 09 --> 9.00 and 9 --> 9.00 - If the user types 4 characters which form a valid "military time" value, the dot `.` character will be added as a separator. - - E.g. 1400 --> 14.00 - -**On change:** - -- If the user types the colon `:` character, it will immediately get replaced with the dot `.` character. Dot is the correct time separator character in the Finnish language. + - E.g. 1400 --> 14.00 and 1745 --> 17.45 +- If the user types the colon `:` character it will be replaced with the dot `.` character. Dot is the correct time separator character in the Finnish language. -- @@ -33,7 +28,7 @@ Examples: Provide a descriptive `labelText` for the input. ```jsx -import { TimeInput } from 'suomifi-ui-components'; +import { TimeInpu, TextInput } from 'suomifi-ui-components'; ; ``` @@ -48,7 +43,6 @@ import { TimeInput } from 'suomifi-ui-components'; console.log(event)} />; ``` @@ -56,14 +50,14 @@ import { TimeInput } from 'suomifi-ui-components'; Use the `defaultValue` prop to provide an initial value for the input. -The value must be of format `H.mm` +The value must be of format H.mm ```jsx import { TimeInput } from 'suomifi-ui-components'; ; ``` diff --git a/src/core/Form/TimeInput/TimeInput.tsx b/src/core/Form/TimeInput/TimeInput.tsx index cf7aaf54d..c04da3861 100644 --- a/src/core/Form/TimeInput/TimeInput.tsx +++ b/src/core/Form/TimeInput/TimeInput.tsx @@ -152,6 +152,11 @@ const BaseTimeInput = (props: TimeInputProps) => { } } + // Change : to . + if (inputValue.includes(':')) { + setInputValue(inputValue.replace(':', '.')); + } + if (!!propOnBlur) { propOnBlur(event); } @@ -191,7 +196,8 @@ const BaseTimeInput = (props: TimeInputProps) => { id={id} className={timeInputClassNames.inputElement} forwardedRef={forkRefs(inputRef, definedRef)} - placeholder={visualPlaceholder} + placeholder="-- . --" + maxlength={5} {...{ 'aria-invalid': status === 'error' }} {...getConditionalAriaProp('aria-describedby', [ statusText ? statusTextId : undefined, @@ -202,15 +208,11 @@ const BaseTimeInput = (props: TimeInputProps) => { if (propOnChange) { debouncer(propOnChange, event.currentTarget.value); } - - if (event.currentTarget.value.includes(':')) { - setInputValue(event.currentTarget.value.replace(':', '.')); - } else { - setInputValue(event.currentTarget.value); - } + setInputValue(event.currentTarget.value); }} onBlur={handleOnBlur} value={controlledValue || inputValue} + inputmode="email" /> )} From b4bb0e4079de9448f374eaf0fd4ed39a3524d150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20P=C3=A4ttikangas?= Date: Tue, 3 Oct 2023 13:58:41 +0300 Subject: [PATCH 08/25] TimeInput: Some more logic and docs --- src/core/Form/TimeInput/TimeInput.md | 140 +++++++++++++++++++++++++- src/core/Form/TimeInput/TimeInput.tsx | 74 +++++++++----- 2 files changed, 187 insertions(+), 27 deletions(-) diff --git a/src/core/Form/TimeInput/TimeInput.md b/src/core/Form/TimeInput/TimeInput.md index 6c3a84b65..b3c07a41a 100644 --- a/src/core/Form/TimeInput/TimeInput.md +++ b/src/core/Form/TimeInput/TimeInput.md @@ -5,6 +5,8 @@ The `` component is used to input times. It looks and behaves similar - E.g. 09 --> 9.00 and 9 --> 9.00 - If the user types 4 characters which form a valid "military time" value, the dot `.` character will be added as a separator. - E.g. 1400 --> 14.00 and 1745 --> 17.45 +- If the user types an otherwise valid time with a leading zero in the hours, the zero will be removed + - E.g. 07.45 --> 7.45 - If the user types the colon `:` character it will be replaced with the dot `.` character. Dot is the correct time separator character in the Finnish language. -- @@ -18,6 +20,12 @@ Examples: - [Basic use](./#/Components/TimeInput?id=basic-use) - [Hint text](./#/Components/TimeInput?id=hint-text) - [Default value](./#/Components/TimeInput?id=default-value) +- [Controlled value](./#/Components/TimeInput?id=controlled-value) +- [Error status and validation](./#/Components/TimeInput?id=error-status-and-validation) +- [Optional input](./#/Components/TimeInput?id=optional-input) +- [Disabled](./#/Components/TimeInput?id=disabled) +- [Hidden label](./#/Components/TimeInput?id=hidden-label) +- [Tooltip](./#/Components/TimeInput?id=tooltip)
[Props & methods](./#/Components/TimeInput?id=props--methods) @@ -50,8 +58,6 @@ import { TimeInput } from 'suomifi-ui-components'; Use the `defaultValue` prop to provide an initial value for the input. -The value must be of format H.mm - ```jsx import { TimeInput } from 'suomifi-ui-components'; @@ -62,6 +68,134 @@ import { TimeInput } from 'suomifi-ui-components'; />; ``` -### Validation +### Controlled value + +Use the `value` prop to programmatically access and control the component's value. + +A typical use case involves setting the state in the `onChange()` function. + +```jsx +import { TimeInput } from 'suomifi-ui-components'; +import { useState } from 'react'; + +const [controlledValue, setControlledValue] = useState(); + + setControlledValue(newVal)} +/>; +``` + +### Error status and validation + +As described at the top of this page, `` automatically fills in or corrects some user inputs on blur event. It does not, however, contain any internal logic to validate inputs or display error messages. Thus, validation has to be implemented externally as shown in the example below. + +Due to the component's autocomplete features, it is recommended to run validation either on blur or on form submit (as opposed to dynamically on change). + +```jsx +import { TimeInput } from 'suomifi-ui-components'; +import { useState } from 'react'; + +const [errorState, setErrorState] = React.useState(false); +const statusText = errorState ? 'Invalid time' : undefined; +const status = errorState ? 'error' : 'default'; + +// String is of type H.mm where H is 0-24 and mm is 0-59 +const isValidTimeString = (timeStr) => { + console.log(timeStr); + if (timeStr.match(/^\d{1,2}.\d{2}$/)) { + const parts = timeStr.split('.'); + const hours = parseInt(parts[0]); + const minutes = parseInt(parts[1]); + if (hours >= 0 && hours < 25 && minutes >= 0 && minutes < 60) { + return true; + } + } + return false; +}; + + setErrorState(!isValidTimeString(inputVal))} +/>; +``` + +### Optional input + +Suomi.fi inputs are required by default, but can be marked optional using the `optionalText` property. + +```jsx +import { TimeInput } from 'suomifi-ui-components'; + +; +``` + +### Disabled + +Disable the input with the `disabled` prop. + +```jsx +import { TimeInput } from 'suomifi-ui-components'; + +; +``` + +### Hidden label + +In some special cases, the label can be hidden from sighted users. This is not recommended though, and should only be done when the field already has another visually connected label. In these cases, labelText should match or include the visual label. + +```jsx +import { TimeInput } from 'suomifi-ui-components'; + +; +``` + +### Tooltip + +A `` component can be used with TimeInput to provide additional information. + +In terms of instructive texts, Tooltip should only be used as a "last resort" when the info text is too long for `hintText`. Tooltip can be used for other nice-to-know information. + +For instructions regarding how to ensure your Tooltip is accessible, please refer to the [Tooltip documentation](./#/Components/Tooltip). + +```js +import { + TimeInput, + Tooltip, + Heading, + Text +} from 'suomifi-ui-components'; + +const labelTextForTooltipExample = 'Opening time'; + + + + About opening times + + + Please provide a general opening time of your business for + most days. This information will be used to lorem ipsum dolor + sit amet. + + + } +/>; +``` ### Props & methods diff --git a/src/core/Form/TimeInput/TimeInput.tsx b/src/core/Form/TimeInput/TimeInput.tsx index c04da3861..f7242a410 100644 --- a/src/core/Form/TimeInput/TimeInput.tsx +++ b/src/core/Form/TimeInput/TimeInput.tsx @@ -1,7 +1,6 @@ import React, { forwardRef, ChangeEvent, - FocusEvent, ReactNode, ReactElement, useRef, @@ -41,7 +40,7 @@ export const timeInputClassNames = { export interface TimeInputProps extends StatusTextCommonProps, - Omit { + Omit { /** CSS class for custom styles */ className?: string; /** Props passed to the outermost div element of the component */ @@ -55,8 +54,11 @@ export interface TimeInputProps onClick?: () => void; /** Callback fired on input change */ onChange?: (value: string) => void; - /** Callback fired on input blur */ - onBlur?: (event: FocusEvent) => void; + /** Callback fired on input blur. + * Notice that this function returns the string value of the input, + * not the blur FocusEvent like in other input components in this library! + */ + onBlur?: (inputValue: string) => void; /** Label for the input */ labelText: ReactNode; /** Hides or shows the label. Label element is always present, but can be visually hidden. @@ -128,37 +130,62 @@ const BaseTimeInput = (props: TimeInputProps) => { const inputRef = useRef(null); const definedRef = forwardedRef || null; - const handleOnBlur = (event: FocusEvent) => { + // String is of type XX:YY or XX.YY where XX is 0-24 and YY is 0-59 + const isValidTimeString = (timeStr: string) => { + if (timeStr.match(/^\d{1,2}.\d{2}$/) || timeStr.match(/^\d{1,2}:\d{2}$/)) { + const parts = timeStr.split(timeStr.includes('.') ? '.' : ':'); + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + if (hours >= 0 && hours < 25 && minutes >= 0 && minutes < 60) { + return true; + } + } + + return false; + }; + + const handleOnBlur = () => { + let adjustedInputValue = ''; const inputValInt = parseInt(inputValue, 10); // Handle automatic filling of 1 or 2 characters: 14 --> 14.00. // Also remove leading zero from hours which are under 10 - if (inputValue.length <= 2 && !Number.isNaN(inputValue)) { - if (inputValInt >= 0 && inputValInt < 25) { - setInputValue(`${inputValInt}.00`); - } + if ( + (inputValue.match(/^\d{1}$/) || inputValue.match(/^\d{2}$/)) && + inputValInt >= 0 && + inputValInt < 25 + ) { + adjustedInputValue = `${inputValInt}.00`; + setInputValue(adjustedInputValue); } // Handle automatic filling of 4 characters: 1400 --> 14.00 - if ( - inputValue.length === 4 && - !Number.isNaN(inputValue) && - /^\d+$/.test(inputValue) + else if ( + inputValue.match(/^\d{4}$/) && + inputValInt >= 0 && + inputValInt < 2500 ) { - if (inputValInt >= 0 && inputValInt < 2500) { - setInputValue( - `${inputValue[0]}${inputValue[1]}.${inputValue[2]}${inputValue[3]}`, - ); - } + adjustedInputValue = `${inputValue[0]}${inputValue[1]}.${inputValue[2]}${inputValue[3]}`; + setInputValue(adjustedInputValue); + } + + // Remove leading zero from an otherwise valid time + else if (isValidTimeString(inputValue) && inputValue[0] === '0') { + adjustedInputValue = `${inputValue[1]}.${inputValue[3]}${inputValue[4]}`; + setInputValue(adjustedInputValue); } - // Change : to . - if (inputValue.includes(':')) { - setInputValue(inputValue.replace(':', '.')); + // Change : to . in an otherwise valid time + else if (isValidTimeString(inputValue)) { + adjustedInputValue = inputValue.replace(':', '.'); + setInputValue(adjustedInputValue); } if (!!propOnBlur) { - propOnBlur(event); + // This is a hack to make sure state (input value) has been updated before executing custom onBlur + setTimeout(() => { + propOnBlur(adjustedInputValue || inputValue); + }, 100); } }; @@ -197,7 +224,7 @@ const BaseTimeInput = (props: TimeInputProps) => { className={timeInputClassNames.inputElement} forwardedRef={forkRefs(inputRef, definedRef)} placeholder="-- . --" - maxlength={5} + maxLength={5} {...{ 'aria-invalid': status === 'error' }} {...getConditionalAriaProp('aria-describedby', [ statusText ? statusTextId : undefined, @@ -212,7 +239,6 @@ const BaseTimeInput = (props: TimeInputProps) => { }} onBlur={handleOnBlur} value={controlledValue || inputValue} - inputmode="email" /> )} From 4cdaa91ec6c4a204512e9d509a308a8bd504515d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20P=C3=A4ttikangas?= Date: Tue, 3 Oct 2023 14:32:35 +0300 Subject: [PATCH 09/25] TimeInput: Add margin props support and adjust autocomplete logic --- .styleguidist/spacingprops.md | 1 + src/core/Form/TextInput/TextInput.tsx | 2 +- src/core/Form/TimeInput/TimeInput.md | 2 ++ src/core/Form/TimeInput/TimeInput.tsx | 29 ++++++++++++++++++--------- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/.styleguidist/spacingprops.md b/.styleguidist/spacingprops.md index 82c618840..89cdb71bc 100644 --- a/.styleguidist/spacingprops.md +++ b/.styleguidist/spacingprops.md @@ -77,6 +77,7 @@ The following components support margin props - Text - Textarea - TextInput +- TimeInput - Toast - ToggleButton - ToggleInput diff --git a/src/core/Form/TextInput/TextInput.tsx b/src/core/Form/TextInput/TextInput.tsx index cfcc587e4..1f3dce8d2 100644 --- a/src/core/Form/TextInput/TextInput.tsx +++ b/src/core/Form/TextInput/TextInput.tsx @@ -104,7 +104,7 @@ interface BaseTextInputProps /** * `'default'` | `'error'` * - * Status of the component. Error state creates a red border around the Checkbox. + * Status of the component. Error state creates a red border around the input. * Always use a descriptive `statusText` with an error status. * @default default */ diff --git a/src/core/Form/TimeInput/TimeInput.md b/src/core/Form/TimeInput/TimeInput.md index b3c07a41a..3c49f17fb 100644 --- a/src/core/Form/TimeInput/TimeInput.md +++ b/src/core/Form/TimeInput/TimeInput.md @@ -199,3 +199,5 @@ const labelTextForTooltipExample = 'Opening time'; ``` ### Props & methods + +TimeInput component supports [margin props](./#/Spacing/Margin%20props) for spacing. diff --git a/src/core/Form/TimeInput/TimeInput.tsx b/src/core/Form/TimeInput/TimeInput.tsx index f7242a410..7fe0bbc42 100644 --- a/src/core/Form/TimeInput/TimeInput.tsx +++ b/src/core/Form/TimeInput/TimeInput.tsx @@ -16,6 +16,11 @@ import { HTMLAttributesIncludingDataAttributes, forkRefs, } from '../../../utils/common/common'; +import { + spacingStyles, + separateMarginProps, + MarginProps, +} from '../../theme/utils/spacing'; import { HtmlInputProps, HtmlDiv, HtmlSpan, HtmlInput } from '../../../reset'; import { Label, LabelMode } from '../Label/Label'; import { StatusText } from '../StatusText/StatusText'; @@ -40,6 +45,7 @@ export const timeInputClassNames = { export interface TimeInputProps extends StatusTextCommonProps, + MarginProps, Omit { /** CSS class for custom styles */ className?: string; @@ -72,7 +78,7 @@ export interface TimeInputProps /** * `'default'` | `'error'` * - * Status of the component. Error state creates a red border around the Checkbox. + * Status of the component. Error status creates a red border around the input. * Always use a descriptive `statusText` with an error status. * @default default */ @@ -122,8 +128,10 @@ const BaseTimeInput = (props: TimeInputProps) => { statusTextAriaLiveMode = 'assertive', 'aria-describedby': ariaDescribedBy, tooltipComponent, - ...passProps + ...rest } = props; + const [marginProps, passProps] = separateMarginProps(rest); + const marginStyle = spacingStyles(marginProps); const [inputValue, setInputValue] = useState(defaultValue || ''); @@ -149,7 +157,7 @@ const BaseTimeInput = (props: TimeInputProps) => { const inputValInt = parseInt(inputValue, 10); // Handle automatic filling of 1 or 2 characters: 14 --> 14.00. - // Also remove leading zero from hours which are under 10 + // Also remove leading zero from hours if ( (inputValue.match(/^\d{1}$/) || inputValue.match(/^\d{2}$/)) && inputValInt >= 0 && @@ -160,12 +168,15 @@ const BaseTimeInput = (props: TimeInputProps) => { } // Handle automatic filling of 4 characters: 1400 --> 14.00 + // Also remove leading zero from hours else if ( inputValue.match(/^\d{4}$/) && - inputValInt >= 0 && - inputValInt < 2500 + isValidTimeString( + `${inputValue[0]}${inputValue[1]}.${inputValue[2]}${inputValue[3]}`, + ) ) { - adjustedInputValue = `${inputValue[0]}${inputValue[1]}.${inputValue[2]}${inputValue[3]}`; + const hoursInt = parseInt(`${inputValue[0]}${inputValue[1]}`, 10); + adjustedInputValue = `${hoursInt}.${inputValue[2]}${inputValue[3]}`; setInputValue(adjustedInputValue); } @@ -182,10 +193,7 @@ const BaseTimeInput = (props: TimeInputProps) => { } if (!!propOnBlur) { - // This is a hack to make sure state (input value) has been updated before executing custom onBlur - setTimeout(() => { - propOnBlur(adjustedInputValue || inputValue); - }, 100); + propOnBlur(adjustedInputValue || inputValue); } }; @@ -201,6 +209,7 @@ const BaseTimeInput = (props: TimeInputProps) => { [timeInputClassNames.success]: status === 'success', [timeInputClassNames.fullWidth]: fullWidth, })} + style={{ ...marginStyle, ...wrapperProps?.style }} >