Tailwind + Mantine | 切一个汇率输入框

419 阅读3分钟

写在前面

现在切一个小组件, 和以前有很大不同

在最开始, 都是 css,scss,lesshtml 进行组合, 然后使用 js 添加交互

后来, 有了 .vue,.jsx,tsx, 将 htmljs 部分合并到一个文件 (.vue 还可以将样式一同合入, 但其实还是独立的一部分, 只是方便了引入和隔离)

再后来, jsx 发展出了 styled-componentheadless-component, 前者以字符串模板的方式将样式也写入 jsx 中, 而后者则纯粹的抽象出业务逻辑

同时, 为了不写 css, 出现了原子度极高, 同时也和构建相结合输出样式的 tailwindcss

发展趋势偏向于 all in one (jsx), dev with build, 前端可以说写的更少, 但要懂得更多

在我最近的项目里, 使用的是 UI 库 (Mantine) + 原子样式 (Tailwind) 的模式, 追求简洁的代码, 复杂的逻辑, 只有 .tsx 和很少一些图片, 大部分的图片也输出为 Svg 的组件

设计图

设计稿内的组件部分是这样的:

image.png

但在实际页面中, 是这样的:

image.png

image.png

以组件部分 (汇率输入框) 为原子进行切图, 然后再封装一个包含顶部左右部分的业务组件

原子组件 -- 汇率输入框

分为 3 部分:

  1. 容器
  2. 输入框
  3. 底部汇率展示部分或操作部分

考虑拓展和变化:

  • 容器样式
  • 输入框样式和配置
  • 输入框焦点状态需要控制容器样式
  • 底部汇率的样式
  • 操作部分的控制

先上代码, 再解释

'use client'

import {
  type ElementProps,
  NumberInput,
  type NumberInputProps,
  Stack,
  type StackProps,
  Text,
  type TextProps,
} from '@mantine/core'

import { useState } from 'react'
import { cn } from '../../utils/className'

export interface ExchangeInputProps
  extends NumberInputProps,
    ElementProps<'input', keyof NumberInputProps> {
  stackProps?: StackProps & ElementProps<'div', keyof StackProps>
  exchangeContent?: string
  exchangeContentProps?: TextProps & ElementProps<'div', keyof TextProps>
  exchangeExternal?: React.ReactNode
}

export const ExchangeInput: React.FC<ExchangeInputProps> = ({
  value,
  onChange,
  stackProps,
  exchangeContent,
  exchangeContentProps,
  exchangeExternal,
  ...inputProps
}) => {
  const [focused, setFocused] = useState(false)

  return (
    <Stack
      {...stackProps}
      className={cn(
        ' border-bd1 bg-bg1 gap-1 rounded-xl border border-solid px-3 py-2 transition',
        focused && 'border-t1',
        stackProps?.className,
      )}>
      <NumberInput
        value={value}
        onChange={event => {
          onChange?.(event)
        }}
        placeholder="0"
        {...inputProps}
        hideControls
        variant="unstyled"
        thousandSeparator=" "
        className={cn('', inputProps?.className)}
        classNames={{
          ...inputProps?.classNames,
          // TODO 有可能是个 function, 如果需要合并, 需要处理一下
          input: cn(' placeholder:text-t3 text-t1 h-10 text-right text-[32px] font-bold'),
        }}
        onFocus={() => setFocused(true)}
        onBlur={() => setFocused(false)}
      />
      {exchangeContent && (
        <Text
          {...exchangeContentProps}
          className={cn(' text-t2 text-right text-sm', exchangeContentProps?.className)}>
          {exchangeContent}
        </Text>
      )}
      {exchangeExternal}
    </Stack>
  )
}

代码解释

使用的 Mantine 的组件, 容器是 Stack, 输入框是 NumberInput, 汇率部分是 Text

接口定义部分

考虑 ExchangeInput 原子组件本体是个 NumberInput 的拓展, 所以 props 继承自 NumberInputProps, 可以参考 Mantine 多态组件继承: mantine.dev/guides/type…

汇率部分 exchangeContent 和对应的 exchangeContentProps (控制 Text)

操作部分 exchangeExternal 作为整体全部由外部传入

内部状态

inputProps 中拆出 valueonChange, 因为有可能会修改它, 产生中间状态, 或拦截状态改变

增加一个 focused 状态, 因为无法用 tailwind 由子控制父 (可以使用 group 由父控制子)

样式部分

className 使用 cn (clsx + tailwind-merge 的组合) 合并后赋值

classNames 先只简单在 NumberInput 里处理一下, 认为它只传入字符串, 如果后续会使用函数再做处理,

展示一下

image.png

示例代码:

'use client'

import { Badge, ExchangeInput, Group, Stack, Text } from '@hidden-name/mantine'
import { BigNumber } from '@hidden-name/number'
import { useState } from 'react'

const exchange = (value: string | number) => {
  return `≈ ${BigNumber(value).times(0.0001).toFormat(3)} ETH`
}

export const UIInputs: React.FC = () => {
  const [value1, setValue1] = useState<string | number>('')
  const [content1, setContent1] = useState<string>(exchange(0))

  const [value2, setValue2] = useState<string | number>(222222)
  const [content2, setContent2] = useState<string>(exchange(value2))

  const [value3, setValue3] = useState<string | number>(123123123)
  const [content3, setContent3] = useState<string>(exchange(value3))

  const [value4, setValue4] = useState<string | number>('')
  const [content4, setContent4] = useState<string>(exchange(0))
  const [percentList] = useState([25, 50, 75, 100])
  const handlePercentClick = (percent: number) => {
    setValue4(percent * 1000)
    setContent4(exchange(percent * 1000))
  }

  const [value5, setValue5] = useState<string | number>('')

  const handleChange =
    (setValue: (payload: string | number) => void, setContent: (payload: string) => void) =>
    (value: string | number) => {
      if (value) {
        setValue(value)
        setContent(exchange(value))
      } else {
        setValue('')
        setContent(exchange(0))
      }
    }

  return (
    <Stack>
      <ExchangeInput
        value={value1}
        exchangeContent={content1}
        onChange={handleChange(setValue1, setContent1)}
      />

      <ExchangeInput
        value={value2}
        exchangeContent={content2}
        onChange={handleChange(setValue2, setContent2)}
      />

      <ExchangeInput
        value={value3}
        exchangeContent={content3}
        onChange={handleChange(setValue3, setContent3)}
      />

      <ExchangeInput
        value={value4}
        onChange={handleChange(setValue4, setContent4)}
        exchangeExternal={
          <Group className=" justify-end gap-1">
            <Text className=" text-t2 text-xs">{content4}</Text>
            {percentList.map(percent => (
              <Badge
                className=" text-l1 cursor-pointer px-2"
                key={percent}
                variant="outline"
                color="#5386A6"
                onClick={() => handlePercentClick(percent)}>
                {percent < 100 ? `${percent}%` : 'MAX'}
              </Badge>
            ))}
          </Group>
        }
      />

      <ExchangeInput value={value5} onChange={setValue5} />
    </Stack>
  )
}