fix: Multiple inputs bugs

Signed-off-by: Avior <github@avior.me>
This commit is contained in:
Florian Bouillon 2022-05-13 12:01:56 +02:00
parent 5e6d88c27e
commit 98cfcf2b95
Signed by: Florian Bouillon
GPG Key ID: BEEAF3722D0EBF64
7 changed files with 7552 additions and 9474 deletions

View File

@ -12,6 +12,9 @@ module.exports = {
"addons": [ "addons": [
"@storybook/addon-essentials" "@storybook/addon-essentials"
], ],
reactOptions: {
strictMode: true
},
typescript: { typescript: {
check: false, check: false,
checkOptions: {}, checkOptions: {},

View File

@ -3,5 +3,6 @@ import './mockNextRouter'
import './mockNextImage' import './mockNextImage'
export const parameters = { export const parameters = {
layout: 'centered' layout: 'centered',
actions: { argTypesRegex: '^on.*' }
} }

16527
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -41,7 +41,7 @@
}, },
"dependencies": { "dependencies": {
"@dzeio/object-util": "^1.3.0", "@dzeio/object-util": "^1.3.0",
"lucide-react": "^0.17.17", "lucide-react": "^0.44.0",
"rollup": "^2.70.1", "rollup": "^2.70.1",
"rollup-plugin-styles": "^4.0.0", "rollup-plugin-styles": "^4.0.0",
"rollup-plugin-typescript2": "^0.31.2", "rollup-plugin-typescript2": "^0.31.2",

View File

@ -1,5 +1,16 @@
@import '../config' @import '../config'
.label
font-size rem(16)
display block
font-weight bold
color black
padding-left 8px
padding-bottom 4px
cursor pointer
@media (prefers-color-scheme dark)
color white
.parent .parent
position relative position relative
max-width 100% max-width 100%
@ -10,6 +21,7 @@
svg svg
position absolute position absolute
user-select none
color $darkGrayLight color $darkGrayLight
@media (prefers-color-scheme dark) @media (prefers-color-scheme dark)
color $darkGrayDark color $darkGrayDark
@ -91,11 +103,11 @@
outline none outline none
background $lightGrayLight background $lightGrayLight
transition all $transition transition all $transition
border 2px solid $darkGrayLight border 2px solid black
color black color black
@media (prefers-color-scheme dark) @media (prefers-color-scheme dark)
background $lightGrayDark background $lightGrayDark
border-color $darkGrayDark border-color transparent
color white color white
&::placeholder &::placeholder
@ -113,14 +125,10 @@
&:disabled &:disabled
border-color #999 border-color $darkGrayLight
@media (prefers-color-scheme dark) @media (prefers-color-scheme dark)
border-color #444 border-color $darkGrayDark
~label
color #444
~ label
color #999
&:not(:disabled) &:not(:disabled)
&:hover &:hover
@ -137,9 +145,6 @@
&:focus &:focus
border-color $main border-color $main
~ label
color @border-color
~ svg ~ svg
color @border-color color @border-color
// &::placeholder // &::placeholder
@ -151,17 +156,11 @@
&:invalid &:invalid
border-color $errorLight border-color $errorLight
~ label
color @border-color
~ svg ~ svg
color @border-color color @border-color
@media (prefers-color-scheme dark) @media (prefers-color-scheme dark)
border-color $errorDark border-color $errorDark
~ label
color @border-color
~ svg ~ svg
color @border-color color @border-color
@ -185,7 +184,6 @@
&.block &.block
&.block input &.block input
&.block select
&.block textarea &.block textarea
width 100% width 100%
display block display block

View File

@ -9,18 +9,27 @@ export default {
component: Component component: Component
} as Meta } as Meta
export const Basic: Story<any> = (args: any) => <Component {...args} /> export const Input: Story<any> = (args: any) => <Component {...args} />
let tmp = Basic.bind({}) let tmp = Input.bind({})
tmp.args = {label: 'Label', helper: 'Helper', maxLength: 6, iconLeft: { tmp.args = {
icon: X, label: 'Label',
transformer: (v: string) => v + 1 helper: 'Helper',
}} maxLength: 6,
// iconLeft: {
// icon: X,
// transformer: (v: string) => v + 1
// },
id: 'pouet',
type: 'number',
step: 10,
defaultValue: 'test'
}
export const Normal = tmp export const Normal = tmp
tmp = Basic.bind({}) tmp = Input.bind({})
tmp.args = {label: 'Label', helper: 'Helper', choices: [ tmp.args = {defaultValue : 'd', label: 'Label', helper: 'Helper', choices: [
'a', 'a',
'a', 'a',
'a', 'a',
@ -31,7 +40,38 @@ tmp.args = {label: 'Label', helper: 'Helper', choices: [
'a', 'a',
'b', 'b',
{value: 'd', display: 'D'}, {value: 'd', display: 'D'},
{value: '4', display: 'Mai'},
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'
], characterCount: true, iconLeft: X} ], iconLeft: {
icon: X,
transformer: (v: string) => {
console.log("POUET :D")
return ""
}
}}
export const AutoComplete = tmp export const AutoComplete = tmp
tmp = Input.bind({})
tmp.args = {block: true, type: 'textarea', defaultValue : 'd', label: 'Label', helper: 'Helper', choices: [
'a',
'a',
'a',
'a',
'a',
'a',
'a',
'a',
'b',
{value: 'd', display: 'D'},
{value: '4', display: 'Mai'},
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'
], iconLeft: {
icon: X,
transformer: (v: string) => {
console.log("POUET :D")
return ""
}
}}
export const TextArea = tmp

View File

@ -1,12 +1,12 @@
import React, { FC } from 'react' import React, { FC, FocusEvent } from 'react'
import { ChevronDown } from 'lucide-react' import { ChevronDown, MinusSquare, PlusSquare } from 'lucide-react'
import Text from '../Text' import Text from '../Text'
import { Icon } from '../interfaces' import { Icon } from '../interfaces'
import { buildClassName } from '../Util' import { buildClassName } from '../Util'
import css from './Input.module.styl' import css from './Input.module.styl'
import Menu from '../Menu' import Menu from '../Menu'
import { objectClone } from '@dzeio/object-util' import { objectOmit } from '@dzeio/object-util'
interface Props extends React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> { interface Props extends React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {
id?: string id?: string
@ -32,34 +32,77 @@ interface Props extends React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLIn
* Always display every choices * Always display every choices
*/ */
displayAllOptions?: boolean displayAllOptions?: boolean
/**
* Handle the change event
* you will be returned the value (for choices too)
*/
onValue?: (newValue: string) => void
/**
* Make the input take the whole width
*/
block?: boolean
/**
* if enabled value will not be sent if it is not contained in the choices
*/
strictChoices?: boolean
} }
interface States { interface States {
textAreaHeight?: number textAreaHeight?: number
value?: string value?: string
displayedValue?: string
valueUpdate: boolean
isInFirstPartOfScreen?: boolean isInFirstPartOfScreen?: boolean
list: Menu['props']['items']
} }
export default class Input extends React.PureComponent<Props, States> { export default class Input extends React.PureComponent<Props, States> {
public state: States = {} public state: States = {
valueUpdate: false,
list: []
}
// any because f*ck types
private inputRef: React.RefObject<HTMLInputElement> = React.createRef() private inputRef: React.RefObject<HTMLInputElement> = React.createRef()
private parentRef: React.RefObject<HTMLDivElement> = React.createRef() private parentRef: React.RefObject<HTMLDivElement> = React.createRef()
public componentDidMount() { public componentDidMount() {
// Handle Text Area
if (this.props.type === 'textarea') { if (this.props.type === 'textarea') {
this.textareaHandler() this.textareaHandler()
} }
// Handle choices
if (this.props.choices) { if (this.props.choices) {
window.addEventListener('scroll', this.parentScroll) window.addEventListener('scroll', this.parentScroll)
this.parentScroll() this.parentScroll()
} }
}
public componentDidUpdate() { // Handle default Value
console.log(this.state) if (this.props.defaultValue) {
if (!this.props.choices) {
this.setState({displayedValue: this.props.defaultValue.toString()})
} else {
const res = this.props.choices.find((it) => {
return typeof it === 'string' ? it === this.props.defaultValue : it.value === this.props.defaultValue
})
if (!res) {
if (this.props.strictChoices) {
this.setState({value: '', displayedValue: ''})
} else {
this.setState({displayedValue: this.props.defaultValue.toString()})
}
return
}
this.setState({
displayedValue: typeof res === 'string' ? res : res.display
})
}
}
} }
public componentWillUnmount() { public componentWillUnmount() {
@ -68,24 +111,56 @@ export default class Input extends React.PureComponent<Props, States> {
} }
} }
public async componentDidUpdate(_: Props, prevStates: States) {
if (prevStates.value !== this.state.value) {
if (this.props.onValue) {
this.props.onValue(this.state.value ?? '')
}
// console.log(`Value updated: ${prevStates.value} -> ${this.state.value}`)
}
// if (prevStates.displayedValue !== this.state.displayedValue) {
// console.log(`Displayed Value updated: ${prevStates.displayedValue} -> ${this.state.displayedValue}`)
// }
if (
prevStates.value !== this.state.value ||
prevStates.displayedValue !== this.state.displayedValue ||
prevStates.valueUpdate !== this.state.valueUpdate
) {
this.setState({list: this.buildList()})
}
}
/**
* return the real value of the field (depending if you a choices and the display value)
* @returns the value of the field
*/
public value(): string | number | readonly string[] | undefined {
return this.state?.value ?? this.state.displayedValue ?? this.props.value ?? undefined
}
public render() { public render() {
const props: Props = objectClone(this.props) const props: Props = objectOmit(this.props, 'iconLeft', 'iconRight', 'inputRed', 'helper', 'choices', 'onValue', 'block', 'defaultValue', 'label')
delete props.label
delete props.iconLeft
delete props.iconRight
delete props.inputRef
delete props.helper
delete props.choices
const baseProps: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> = { const baseProps: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> = {
placeholder: this.props.label || this.props.placeholder || ' ',
ref: this.props.inputRef || this.inputRef, ref: this.props.inputRef || this.inputRef,
className: buildClassName( className: buildClassName(
[css.iconLeft, this.props.iconLeft], [css.iconLeft, this.props.type === 'number' || this.props.iconLeft],
[css.iconRight, this.props.iconRight || this.props.choices] [css.iconRight, this.props.type === 'number' || this.props.iconRight || this.props.choices]
), ),
onInvalid: (ev: React.FormEvent<HTMLInputElement>) => ev.preventDefault(), onInvalid: (ev: React.FormEvent<HTMLInputElement>) => ev.preventDefault(),
onFocus: (ev: FocusEvent<HTMLInputElement, Element>) => {
this.setState({valueUpdate: false})
if (props.onFocus) {
props.onFocus(ev)
} }
},
value: this.state.displayedValue ?? this.state.value ?? this.props.value,
onChange: this.onChange
}
let iconRight = this.props.iconRight
let iconLeft = this.props.iconLeft
let input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> let input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
@ -106,34 +181,52 @@ export default class Input extends React.PureComponent<Props, States> {
break break
case 'number': case 'number':
baseProps.onWheel = (ev: React.WheelEvent<HTMLInputElement>) => ev.currentTarget.blur() baseProps.onWheel = (ev: React.WheelEvent<HTMLInputElement>) => ev.currentTarget.blur()
iconLeft = this.props.iconLeft ?? {icon: MinusSquare, transformer: (v) => {
let value = parseFloat(v)
if (isNaN(value)) {
value = 0
}
return (value - this.ensureNumber(this.props.step, 1)).toString()
}}
iconRight = this.props.iconRight ?? {icon: PlusSquare, transformer: (v) => {
let value = parseFloat(v)
if (isNaN(value)) {
value = 0
}
return (value + this.ensureNumber(this.props.step, 1)).toString()
}}
default: default:
input = ( input = <input
<input
{...props} {...props}
{...baseProps} {...baseProps}
/> />
)
} }
return ( return (
<>
{this.props.label && (
<label className={css.label} htmlFor={this.props.id}>{this.props.label}</label>
)}
<div <div
className={buildClassName( className={buildClassName(
css.parent css.parent,
[css.block, this.props.block]
)} )}
onChangeCapture={this.onChange}
ref={this.parentRef} ref={this.parentRef}
> >
{input as any} {input as any}
{/* Left Icon */} {/* Left Icon */}
{this.getIcon('left')} {this.getIcon(iconLeft, 'left')}
{/* Right Icon */} {/* Right Icon */}
{this.props.iconRight ? {iconRight ?
this.getIcon('right') : this.getIcon(iconRight, 'right') :
(this.props.choices && !this.props.disabled) && ( (this.props.choices && !this.props.disabled) && (
<ChevronDown size="18" className={buildClassName(css.right, css.rotate)} /> <ChevronDown size="18" className={buildClassName(css.right, css.rotate)} />
)} )
}
{/* Helper text */} {/* Helper text */}
{(this.props.helper) && ( {(this.props.helper) && (
@ -142,123 +235,131 @@ export default class Input extends React.PureComponent<Props, States> {
{/* List when this is an autocomplete */} {/* List when this is an autocomplete */}
{this.props.choices && ( {this.props.choices && (
// <ul className={buildClassName(css.autocomplete, [css.reverse, !this.state.isInFirstPartOfScreen])}>
// {this.props.choices
// .map((item, index) => typeof item === 'string' ? ({item: {display: item, value: item}, index}) : {item, index})
// .filter(
// (item) => !this.getValue() || [item.item.display.toLowerCase(), item.item.value.toLowerCase()]
// .includes(this.getValue())
// )
// .map((item) => (<li key={item.index} onClick={this.onAutoCompleteClick(item.index)}><Text>{item.item.display}</Text></li>))}
// </ul>
<Menu <Menu
outline outline
hideWhenEmpty hideWhenEmpty
className={buildClassName(css.autocomplete, [css.reverse, !this.state.isInFirstPartOfScreen])} className={buildClassName(css.autocomplete, [css.reverse, !this.state.isInFirstPartOfScreen])}
items={this.buildList()} items={this.state.list ?? []}
onClick={this.listSelection} onClick={this.listSelection}
/> />
)} )}
</div> </div>
</>
) )
} }
private ensureNumber(item: string | number | undefined, defaultValue: number): number {
return typeof item === 'string' ? parseFloat(item) : item ?? defaultValue
}
/** /**
* event for autocomplete to detect where on the screen it shoul display * event for the menu to detect where on the screen it should be displayed
*/ */
private parentScroll = async () => { private parentScroll = async () => {
const div = this.parentRef.current const div = this.parentRef.current
if (!div) {return} if (!div) {return}
const result = !(div.offsetTop - window.scrollY >= window.innerHeight / 2) const result = !(div.offsetTop - window.scrollY >= window.innerHeight / 2)
// console.log(result, div, this.state.isInFirstPartOfScreen)
if (this.state.isInFirstPartOfScreen !== result) { if (this.state.isInFirstPartOfScreen !== result) {
this.setState({isInFirstPartOfScreen: result}) this.setState({isInFirstPartOfScreen: result})
} }
} }
/**
* Build the interactive list for the item
* @returns the list
*/
private buildList(): Menu['props']['items'] { private buildList(): Menu['props']['items'] {
if (!this.props.choices) { if (!this.props.choices) {
return [] return []
} }
const v = this.getValue().toLowerCase() const v = this.state.displayedValue?.toLowerCase()
return this.props.choices return this.props.choices
.map((item, index) => typeof item === 'string' ? ({item: {display: item, value: item}, index}) : {item, index}) .map((item, index) => typeof item === 'string' ? ({item: {display: item, value: item}, index}) : {item, index})
.filter( .filter(
(item) => this.props.displayAllOptions || !v || item.item.display.toLowerCase().includes(v) || item.item.display.toLowerCase().toLowerCase().includes(v) (item) => this.props.displayAllOptions || !this.state.valueUpdate || !v || item.item.display.toLowerCase().includes(v) || item.item.display.toLowerCase().toLowerCase().includes(v)
) )
.map((item) => ({display: item.item.display, value: item.index})) .map((item) => item.item)
} }
private listSelection: Menu['props']['onClick'] = async (value: number, key) => { /**
const newValue = this.props.choices?.[value] * handle when an item is selected
* @param key the index of the selected item
*/
private listSelection: Menu['props']['onClick'] = async (_, key) => {
const newValue = this.state.list[key]
if (!newValue) { if (!newValue) {
return return
} }
if (typeof newValue === 'string') { if (typeof newValue === 'string') {
return this.setValue(newValue) this.setState({value: newValue, displayedValue: newValue, valueUpdate: true})
} return
await this.setValue(newValue.display)
this.setState({value: newValue.value})
} }
private getIcon(icon: 'left' | 'right') { this.setState({displayedValue: newValue.display, value: newValue.value, valueUpdate: true})
const Icon = icon === 'left' ? this.props.iconLeft : this.props.iconRight }
/**
* get the icon duh
* @param icon the icon
* @returns the icon
*/
private getIcon(Icon: Icon | {
icon: Icon;
transformer: (value: string) => string;
} | undefined, position: 'left' | 'right') {
if (!Icon) { if (!Icon) {
return undefined return undefined
} }
if ('icon' in Icon) { if ('icon' in Icon) {
return <Icon.icon size="18" className={buildClassName(css[icon], css.iconClickable)} onClick={() => { return <Icon.icon size="18" className={buildClassName(css[position], css.iconClickable)} onClick={async () => {
const el = this.getElement() const value = Icon.transformer(this.state.value ?? this.state.displayedValue ?? '')
console.log(el, 'pouet') this.setState({ value: value, displayedValue: value, valueUpdate: true })
if (!el) {
return
}
el.value = Icon.transformer(el.value)
}} /> }} />
} }
return <Icon size="18" className={css[icon]} /> return <Icon size="18" className={css[position]} />
} }
private getValue(): string { /**
return this.state?.value?.toLowerCase() ?? this.props.value?.toString().toLowerCase() ?? '' * Handle textarea height changes
} */
private textareaHandler = async () => {
private getElement(): undefined | HTMLInputElement {
const item = this.props.inputRef || this.inputRef
if (!item || !item.current) {return}
return item.current
}
private textareaHandler = async () =>
this.setState({textAreaHeight: undefined}, () => { this.setState({textAreaHeight: undefined}, () => {
if (!this.inputRef.current) {return} if (!this.inputRef.current) {return}
this.setState({textAreaHeight: this.inputRef.current.scrollHeight}) this.setState({ textAreaHeight: this.inputRef.current.scrollHeight })
}) })
private async setValue(value: string) {
const item = this.getElement()
if (!item) {return}
const valueSetter = Object.getOwnPropertyDescriptor(item, 'value')?.set
const prototype = Object.getPrototypeOf(item)
const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
if (valueSetter && valueSetter !== prototypeValueSetter) {
// @ts-expect-error IDK why
prototypeValueSetter.call(item, value)
} else {
// @ts-expect-error IDK why
valueSetter.call(item, value)
}
item.dispatchEvent(new Event('input', {bubbles: true}))
if (this.props.type === 'textarea') {
await this.parentScroll()
}
} }
private onChange = async (event?: React.FormEvent<HTMLDivElement>) => { /**
if (event) { * handle the change event of the input
this.setState({value: (event.target as HTMLInputElement).value }) * @param event the event
*/
private onChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
// get the input
const value = event.currentTarget.value
// console.log("onChange", value)
if (typeof value !== 'string') {
return
} }
if (this.props.onChange) {
this.props.onChange(event)
}
if (this.props.value) {
return
}
if (this.props.strictChoices) {
this.setState({ displayedValue: value, valueUpdate: true })
return
}
this.setState({ value: value, displayedValue: value, valueUpdate: true })
} }
} }