Signed-off-by: Avior <github@avior.me>
This commit is contained in:
2021-10-06 17:57:59 +02:00
parent bb001148a5
commit 8d7a8c70f0
73 changed files with 1508 additions and 6817 deletions

191
src/Input/Input.module.styl Normal file
View File

@ -0,0 +1,191 @@
@import '../config'
.parent
position relative
max-width 100%
display inline-block
&:not(.block) + .parent:not(.block)
margin-left 16px
svg
position absolute
color $darkGrayLight
@media (prefers-color-scheme dark)
color $darkGrayDark
transition color $transition
pointer-events none
&.iconClickable
pointer-events all
cursor pointer
top 14px
&.left
left 16px // input padding-left
~ label
left 16px + 24px + 10px
&.right
right 16px
select
appearance none
option
background $foregroundLight
color black
@media (prefers-color-scheme dark)
background lighten($foregroundDark, 5%)
color white
textarea
resize none
overflow-y hidden
/* Remove the arrows from the Number Input */
input[type="number"]
-moz-appearance textfield
input::-webkit-outer-spin-button
input::-webkit-inner-spin-button
-webkit-appearance none
margin 0
/* End */
.autocomplete
opacity 0
transition all $transition
overflow-x hidden
pointer-events none
position absolute
top calc(100% + 16px)
left 0
width 100%
z-index 100
max-height 25vh
overflow-y auto
@media (max-width $mobile)
max-height 50vh
&.reverse
top initial
bottom calc(100% + 16px)
div + .autocomplete
top 100%
input:focus ~ .autocomplete
select:focus ~ .autocomplete
textarea:focus ~ .autocomplete
.autocomplete:hover
opacity 1
pointer-events inherit
input
select
textarea
padding 12px
border-radius 8px
max-width 100%
font-size .875rem
outline none
background $lightGrayLight
transition all $transition
border 2px solid $darkGrayLight
color black
@media (prefers-color-scheme dark)
background $lightGrayDark
border-color $darkGrayDark
color white
&::placeholder
font-weight 700
font-size rem(16)
transition color $transition
opacity 1
// color $darkGrayLight
// @media (prefers-color-scheme dark)
// color $darkGrayDark
color black
@media (prefers-color-scheme dark)
color white
&:disabled
border-color #999
@media (prefers-color-scheme dark)
border-color #444
~label
color #444
~ label
color #999
&:not(:disabled)
&:hover
border-color black
@media (prefers-color-scheme dark)
border-color white
~ svg
&::placeholder
color black
@media (prefers-color-scheme dark)
color white
&:focus
border-color $main
~ label
color @border-color
~ svg
color @border-color
// &::placeholder
// color black
// @media (prefers-color-scheme dark)
// color white
&:invalid
border-color $errorLight
~ label
color @border-color
~ svg
color @border-color
@media (prefers-color-scheme dark)
border-color $errorDark
~ label
color @border-color
~ svg
color @border-color
&.iconLeft
padding-left 16px + 24px + 10px
&.iconRight
padding-right 16 + 24 + 10px
~ svg.rotate
transform rotateX(0)
transition $transition
&:focus ~ svg.rotate
~ .autocomplete:hover ~ svg.rotate
transform rotateX(180deg)
p
padding 0 8px
font-size rem(14)
&.block
&.block input
&.block select
&.block textarea
width 100%
display block

View File

@ -0,0 +1,37 @@
import { Meta } from '@storybook/react/types-6-0'
import { Story } from "@storybook/react"
import React from 'react'
import Component from '.'
import { X } from 'lucide-react'
export default {
title: 'DZEIO/Input',
component: Component
} as Meta
export const Basic: Story<any> = (args: any) => <Component {...args} />
let tmp = Basic.bind({})
tmp.args = {label: 'Label', helper: 'Helper', maxLength: 6, iconLeft: {
icon: X,
transformer: (v: string) => v + 1
}}
export const Normal = tmp
tmp = Basic.bind({})
tmp.args = {label: 'Label', filled:true, helper: 'Helper', choices: [
'a',
'a',
'a',
'a',
'a',
'a',
'a',
'a',
'b',
{value: 'd', display: 'D'},
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'
], characterCount: true, iconLeft: X}
export const AutoComplete = tmp

264
src/Input/index.tsx Normal file
View File

@ -0,0 +1,264 @@
import React, { FC } from 'react'
import { ChevronDown } from 'lucide-react'
import Text from '../Text'
import { Icon } from '../interfaces'
import { buildClassName } from '../Util'
import css from './Input.module.styl'
import Menu from '../Menu'
import { objectClone } from '@dzeio/object-util'
interface Props extends React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {
id?: string
label?: string
iconLeft?: Icon | {
icon: Icon
transformer: (value: string) => string
}
iconRight?: Icon | {
icon: Icon
transformer: (value: string) => string
}
helper?: string
inputRef?: React.RefObject<HTMLInputElement>
type?: 'color' | 'text' | 'date' | 'datetime-local' |
'email' | 'file' | 'month' | 'number' | 'password' |
'range' | 'search' | 'tel' | 'time' | 'url' | 'week' |
// Custom Types
'textarea'
choices?: Array<string | {display: string, value: string}>
/**
* Always display every choices
*/
displayAllOptions?: boolean
}
interface States {
textAreaHeight?: number
value?: string
isInFirstPartOfScreen?: boolean
}
export default class Input extends React.PureComponent<Props, States> {
public state: States = {}
// any because f*ck types
private inputRef: React.RefObject<HTMLInputElement> = React.createRef()
private parentRef: React.RefObject<HTMLDivElement> = React.createRef()
public componentDidMount() {
if (this.props.type === 'textarea') {
this.textareaHandler()
}
if (this.props.choices) {
window.addEventListener('scroll', this.parentScroll)
this.parentScroll()
}
}
public componentDidUpdate() {
console.log(this.state)
}
public componentWillUnmount() {
if (this.props.choices) {
window.removeEventListener('scroll', this.parentScroll)
}
}
public render() {
const props: Props = objectClone(this.props)
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> = {
placeholder: this.props.label || this.props.placeholder || ' ',
ref: this.props.inputRef || this.inputRef,
className: buildClassName(
[css.iconLeft, this.props.iconLeft],
[css.iconRight, this.props.iconRight || this.props.choices]
),
onInvalid: (ev: React.FormEvent<HTMLInputElement>) => ev.preventDefault(),
}
let input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
switch (this.props.type) {
case 'textarea':
delete baseProps.ref
input = (
<textarea
{...props as React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>}
{...baseProps as any}
ref={this.inputRef}
style={{minHeight: this.state?.textAreaHeight}}
onKeyDown={this.textareaHandler}
onKeyUp={this.textareaHandler}
onFocus={this.textareaHandler}
/>
)
break
case 'number':
baseProps.onWheel = (ev: React.WheelEvent<HTMLInputElement>) => ev.currentTarget.blur()
default:
input = (
<input
{...props}
{...baseProps}
/>
)
}
return (
<div
className={buildClassName(
css.parent
)}
onChangeCapture={this.onChange}
ref={this.parentRef}
>
{input as any}
{/* Left Icon */}
{this.getIcon('left')}
{/* Right Icon */}
{this.props.iconRight ?
this.getIcon('right') :
(this.props.choices && !this.props.disabled) && (
<ChevronDown size="18" className={buildClassName(css.right, css.rotate)} />
)}
{/* Helper text */}
{(this.props.helper) && (
<Text>{this.props.helper}</Text>
)}
{/* List when this is an autocomplete */}
{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
outline
hideWhenEmpty
className={buildClassName(css.autocomplete, [css.reverse, !this.state.isInFirstPartOfScreen])}
items={this.buildList()}
onClick={this.listSelection}
/>
)}
</div>
)
}
/**
* event for autocomplete to detect where on the screen it shoul display
*/
private parentScroll = async () => {
const div = this.parentRef.current
if (!div) {return}
const result = !(div.offsetTop - window.scrollY >= window.innerHeight / 2)
// console.log(result, div, this.state.isInFirstPartOfScreen)
if (this.state.isInFirstPartOfScreen !== result) {
this.setState({isInFirstPartOfScreen: result})
}
}
private buildList(): Menu['props']['items'] {
if (!this.props.choices) {
return []
}
const v = this.getValue().toLowerCase()
return this.props.choices
.map((item, index) => typeof item === 'string' ? ({item: {display: item, value: item}, index}) : {item, index})
.filter(
(item) => this.props.displayAllOptions || !v || item.item.display.toLowerCase().includes(v) || item.item.display.toLowerCase().toLowerCase().includes(v)
)
.map((item) => ({display: item.item.display, value: item.index}))
}
private listSelection: Menu['props']['onClick'] = async (value: number, key) => {
const newValue = this.props.choices?.[value]
if (!newValue) {
return
}
if (typeof newValue === 'string') {
return this.setValue(newValue)
}
await this.setValue(newValue.display)
this.setState({value: newValue.value})
}
private getIcon(icon: 'left' | 'right') {
const Icon = icon === 'left' ? this.props.iconLeft : this.props.iconRight
if (!Icon) {
return undefined
}
if ('icon' in Icon) {
return <Icon.icon size="18" className={buildClassName(css[icon], css.iconClickable)} onClick={() => {
const el = this.getElement()
console.log(el, 'pouet')
if (!el) {
return
}
el.value = Icon.transformer(el.value)
}} />
}
return <Icon size="18" className={css[icon]} />
}
private getValue(): string {
return this.state?.value?.toLowerCase() ?? this.props.value?.toString().toLowerCase() ?? ''
}
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}, () => {
if (!this.inputRef.current) {return}
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) {
this.setState({value: (event.target as HTMLInputElement).value })
}
}
}