跳到主要内容

4 篇博文 含有标签「css」

查看所有标签

· 阅读需 4 分钟

记录下瀑布流布局实现方案

生成指定大小的随机图片: https://picsum.photos/width/height

初始页面如下,共有 10 张宽度固定,高度随机的图片

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>瀑布流</title>
</head>
<body>
<div class="container"></div>
</body>
<script>
// 生成随机图片
const WIDTH = 300
const HEIGHT_ARR = [200, 250, 300, 350, 400, 450, 500, 600]
const getRandomUrl = () => {
const height = HEIGHT_ARR[Math.floor(Math.random() * 8)]
return `https://picsum.photos/${WIDTH}/${height}`
}

function generateImages(count = 10) {
const container = document.querySelector('.container')
for (let i = 0; i < count; i++) {
const img = document.createElement('img')
img.src = getRandomUrl()
container.appendChild(img)
}
}

generateImages()
</script>
</html>

方案一:纯 css 布局

将 container 设置为 grid 布局,固定列宽

实现核心是 grid-template-rows: masonry;

.container {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: masonry;
grid-gap: 10px;
}

caniuse 网站显示,至今只有最新版的 Safari 浏览器支持这一特性。。。

方案二:js 计算布局

整体布局信息

根据容器宽度和图片宽度,计算共有多少列,剩余空间平均分配,作为每列的间隙

function cal() {
const containerWidth = container.clientWidth
const columns = Math.floor(containerWidth / IMG_WIDTH) // 总列数
const spaceNumber = columns + 1 // 间隙个数
// 剩余空间平均分配,作为间隙
const leftSpace = containerWidth - columns * IMG_WIDTH
const space = leftSpace / spaceNumber
return {
space,
columns
}
}

元素的定位

设置定位:容器 relative,图片 absolute

思路:维护一个数组,数组每项记录每列的当前高度,那么下一张照片就放在最小高度的那一列

  1. 用列数作为 length,初始化数组 nextTops,默认坐标为 0

  2. 循环每个 img 元素

    1. 找到 nextTops 中的最小高度,作为 img 的 top
    2. 更新该列的纵坐标(记得算上间隙)
    3. 根据更新列 nextTops 的 index 位置(用最小高度去找),计算图片的 left(加上间隙)
    4. 最后设置容器的高度为 nextTops 的最大值,即最大列高
// 以计算出来的列数为 length,初始化纵坐标数组,默认坐标为 0
const info = cal()
const nextTops = new Array(info.columns)
nextTops.fill(0)

// 循环每个 img 元素
for (let i = 0; i < container.children.length; i++) {
const img = container.children[i]
// 找到列中的最小高度,作为图片的 top
const minTop = Math.min.apply(null, nextTops)
img.style.top = minTop + 'px'
// 更新该列的纵坐标(记得算上间隙)
const index = nextTops.indexOf(minTop)
nextTops[index] += img.height + info.space
// 根据列的位置,计算图片的 left
const left = (index + 1) * info.space + index * IMG_WIDTH
img.style.left = left + 'px'
}

// 设置容器高度为最大纵坐标的列高
const max = Math.max.apply(null, nextTops)
container.style.height = max + 'px'

效果如下

image-20240807191539611

· 阅读需 9 分钟

本文参考 shadcn-ui 的组件封装方案,提供了一种基于业务或者团队的 UI 设计方案,对 Tailwind CSS 组件的样式进行进一步的封装思路

有关于 shadcn-ui 的介绍,可以参考 《discord-clone 项目总结》一文

概述

Tailwind CSS 已经为开发者提供了非常丰富的基本样式类的封装,覆盖绝大部分的样式需求。主要具有如下优势

  1. 不用思考命名
  2. 不用担心 css 作用域的问题,从而可以避免使用 scss、less、css modules、css in js 等额外的技术方案
  3. 不用频繁的额外单独创建一个 css 文件,可以直接在 html 或 jsx 中表达样式
  4. 打包体积变小
  5. 稍作修改,可以极大的提高项目的可维护性
  6. 极大的提高了开发效率
  7. 最重要的是开发变得更加顺畅,所见即所得,不用样式分离

下面以 Form Input 组件的封装为例,实现

  • 基本的交互特效
  • Form 响应式
  • 支持 colSpan 属性
  • 支持主题切换

工具库

shadcn-ui 组件的样式灵活性主要就是由 clsxcva 库提供的

cva

https://cva.style/docs

cva 可以配置和生成预设样式库,适用于组件的样式封装

variant 是 cva 的核心概念之一,可以理解为某套预设样式的别名,使组件样式可以适配不同的主题或场景,例如 Button 的 primary、ghost 等

变体的定义就是将预设样式与变体样式传入

import { cva } from "class-variance-authority";

const button = cva(["font-semibold", "border", "rounded"], {
variants: {
intent: {
primary: [
"bg-blue-500",
"text-white",
"border-transparent",
"hover:bg-blue-600",
],
// **or**
// primary: "bg-blue-500 text-white border-transparent hover:bg-blue-600",
secondary: [
"bg-white",
"text-gray-800",
"border-gray-400",
"hover:bg-gray-100",
],
},
size: {
small: ["text-sm", "py-1", "px-2"],
medium: ["text-base", "py-2", "px-4"],
},
},
defaultVariants: {
intent: "primary",
size: "medium",
},
});

调用这个函数,就会根据传入的 varient 返回样式字符串

clsx

https://github.com/lukeed/clsx

clsx 是一个拼接 class 的库,用于有条件地构造 className 字符串。clsx 允许我们使用 classname: boolean 的形式,动态控制 TailwindCSS 类名,更方便的通过条件去控制样式的变化

使用 tailwind-merge 库用来处理 tailwind 样式冲突问题,它可以让写在后面的样式覆盖前面的样式,这样我们就不需要使用 !important 来覆盖样式了。

以上二者结合,可以作为 TailwindCSS 中的类名拼接方案

import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

使用

<div className={
cn(
"p-4 border border-indigo-700",
{ "border-rose-700": theme === 'light' }
)
}>

另外,clsx 非常适用于纯样式或者布局组件的封装,将 props 映射为相应的 className

import clsx from 'clsx'

export default function Flex(props) {
const {children, start, end, around, between, className, center, col, ...other} = props

const base = 'flex items-center flex-row'

const cls = clsx(base, {
['flex-col']: col,
['justify-start']: start,
['justify-end']: end,
['justify-around']: around,
['justify-between']: between,
['justify-center']: center,
}, className)

return (
<div className={cls}>{children}</div>
)
}

组件封装

Form 响应式布局

使用 grid 来布局表单项,使用 Tailwind 提供的响应式类名实现响应式 grid 布局

import { FC, ReactNode } from "react"

const Form: FC<{children: ReactNode}> = ({ children }) => {
return (
<form className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{children}
</form>
)
}

export default Form

colSpan

Form Input 组件接收 colSpan 参数,从而动态调整自己在网格中的位置和跨度

import { VariantProps } from 'class-variance-authority'

interface FormInputProps extends VariantProps<typeof containerStyles> {
label: string
name: string
className?: string
}

这里要明确,colSpan 并非业务逻辑的参数,而是样式的 variant,所以我们使用 cva 来定义并管理这个 variant

const containerStyles = cva(
'w-full p-2 border rounded-md relative',
{
variants: {
colSpan: {
1: 'col-span-1',
2: 'col-span-2',
3: 'col-span-3',
},
},
defaultVariants: {
colSpan: 1,
},
}
)

const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({ label, name, colSpan, className }, ref) => {
})

这里组件使用 React.forwardRef 方法包裹,目的是将父组件的引用(ref)传递到子组件内部的 DOM 元素上,方便 Form 组件统一管理子组件的 ref,例如操作表单 DOM 元素或者暴露组件内部引用等

forwardRef 更适用多层嵌套传递 ref 的场景

交互

Tailwind 提供了一些实用的状态变体伪类变体。它们允许开发者根据元素的状态、兄弟元素的状态或父元素的状态来应用不同的样式

  • peer:用于关联兄弟元素的状态。例如,当一个 input 元素聚焦时,可以改变与之相关的其他元素的样式。
  • group:用于关联父元素的状态。例如,当一个父元素被悬停时,可以改变其子元素的样式。
  • has-[]:用于根据某些条件来选择元素的状态。
  • first, last, odd, even 等:用于选择元素在其父元素中的位置,并根据该位置应用样式。

这里实现 label 根据 input 聚焦自动缩放的效果

<div className="w-full p-2 border rounded-md relative">
<input
type="text"
name={name}
id={name}
placeholder=' '
className="
block
mt-3
w-full
appearance-none
focus:outline-none
focus:ring-0
peer
"
/>
<label
htmlFor={name}
className="
absolute
text-sm
font-medium
duration-300
z-10
origin-[0]
-translate-y-3
scale-75
top-4
left-2
peer-placeholder-shown:scale-100
peer-placeholder-shown:translate-y-0
peer-focus:scale-75
peer-focus:-translate-y-3
peer-focus:text-zinc-400
"
>
{label}
</label>
</div>

主题切换

定义主题的 context hook 及 Provider 组件,向子组件提供 theme state 和 toggleTheme 方法

import { createContext, FC, ReactNode, useContext, useState } from 'react'

interface ThemeContextType {
theme: 'light' | 'dark'
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType>({theme: 'light', toggleTheme: () => {}})

export const ThemeProvider:FC<{children: ReactNode}> = ({ children }) => {
const [theme, setTheme] = useState<ThemeContextType['theme']>('light')

const toggleTheme = () => {
setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'))
}

return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}

export const useTheme = () => useContext(ThemeContext)

子组件中通过 useTheme hook 获取 theme 数据,并将 theme 属性使用 cva 作为 variant 封装

const containerStyles = cva(
'w-full p-2 border rounded-md relative',
{
variants: {
colSpan: {},
theme: {
light: 'bg-slate-100 has-[:focus]:bg-slate-200',
dark: 'bg-slate-700 text-white',
},
},
defaultVariants: {
colSpan: 1,
theme: 'light',
},
}
)

const inputStyles = cva(
"block mt-3 w-full appearance-none focus:outline-none focus:ring-0 peer",
{
variants: {
theme: {
light: 'bg-slate-100 focus:bg-slate-200',
dark: 'bg-slate-700',
},
},
defaultVariants: {
theme: 'light',
},

}
)

const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({ label, name, colSpan, className }, ref) => {

const { theme } = useTheme()

return (
<div className={cn(containerStyles({ colSpan, theme }), className)}>
<input className={cn(inputStyles({ theme }))} />
<label>{label}</label>
</div>
)
})

export default FormInput

附:Tailwind 多主题配置

使用 CSS 变量和 Tailwind 的扩展配置

定义 CSS 变量

/* theme.css */
.theme-light {
--color-main-bg: #ffffff;
--color-main-text: #000000;
}

.theme-warm {
--color-main-bg: #d8d1c5;
--color-main-text: #474b52;
}

/* main.css */
@import url(./theme.css)

扩展或自定义 Tailwind 主题配置

// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
mainBg: 'var(--color-main-bg)',
mainText: 'var(--color-main-text)'
},
},
},
}

使用

// 在最外层例如 Layout 或者 Provider 的 div 上加上主题名称即可
export const Layout = () => {
const { theme } = useTheme()

return (
<div className={`theme-${theme}`}></div>
)
}

· 阅读需 2 分钟

tailwindcss 虽然写起来很爽,心智负担非常小,但组件样式过长,难以维护等问题也会随着项目复杂度的提升而暴露出来

如何在实际项目中是管理和组织样式类,以确保代码的可读性和可维护性?

解决方案 通过 @apply 命令复用样式

可以将重复样式抽离为一个类

使用 @apply 命令可以在 css 类中应用 tailwind 的类名

.custom-class {
@apply bg-rose-500 text-2xl shadow-xl
}

如何自定义样式

有时 tailwind 内置的预设样式不能很好地适配项目需求,那么就需要扩展一些自定义样式

// tailwind.config.js
module.exports = {
theme: {
extend: {
// 自定义颜色
colors: {
'custom-blue': '#007bff',
'custom-gray': '#6c757d',
},
// 自定义字体大小
fontSize: {
'xs': '.75rem',
'sm': '.875rem',
'tiny': '.875rem',
'base': '1rem',
'lg': '1.125rem',
'xl': '1.25rem',
'2xl': '1.5rem',
'3xl': '1.875rem',
'4xl': '2.25rem',
'5xl': '3rem',
'6xl': '4rem',
'7xl': '5rem',
},
// 自定义间距
spacing: {
'128': '32rem',
'144': '36rem',
},
// 自定义边框半径
borderRadius: {
'xl': '.75rem',
},
// 自定义阴影
boxShadow: {
'custom': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
},
// 自定义过渡时间
transitionDuration: {
'0': '0ms',
'2500': '2500ms',
},
// 自定义动画
animation: {
spin: 'spin 3s linear infinite',
ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite',
},
// 自定义键盘帧
keyframes: {
wiggle: {
'0%, 100%': { transform: 'rotate(-3deg)' },
'50%': { transform: 'rotate(3deg)' },
},
},
// 自定义 z-index
zIndex: {
'100': '100',
'110': '110',
// 更多自定义层级
},
// 自定义断点
screens: {
'sm': '640px',
'md': '768px',
'lg': '1024px',
'xl': '1280px',
'2xl': '1536px',
},
},
},
plugins: [],
}

· 阅读需 5 分钟

组件源码:https://github.com/GSemir0418/account-app-vite/tree/main/src

综述

使用 CSS Scroll SnapIntersection Observer API 实现移动端的 Tab 页签移动效果

本质上就是将 Tab 页面横向排列,使用原生的滚动效果实现页签的切换,使用以上两种技术来处理交互

类型定义

接受 tabList 列表,每一项分别是一个 tab 页面与按钮属性的配置

接受 defaultKey 作为默认展示的 tab,这里使用泛型限制 defaultKey 要属于 tab.key 属性的集合

interface TabProps<T extends React.Key> {
tabList: {
key: T
label1: string
label2: string
className: string
value: TagSummary[]
}[]
defaultKey: T
}

布局

整体为 flex 上下布局,上面是 tab 按钮,剩下是 tab 页面

<div className="flex flex-col">
<div className="w-full">
tab 按钮栏
</div>
<div className="flex-1 w-full relative">
tab 页面
</div>
</div>
  • 页面横向排列,触发横向滚动

这里将外部 tab 容器设置为 flex 布局,支持滚动,内部页面设置最小宽度为 100 %

<div className="flex-1 w-full relative flex overflow-auto">
{tabList.map(tab => (
<div key={tab.key} className="h-full min-w-full">
这里是 tab 内容
</div>
))}
</div>
  • 设置 CSS Snap Scroll
<div className="flex-1 w-full relative flex overflow-auto snap-x snap-mandatory">
{tabList.map(tab => (
<div key={tab.key} className="h-full min-w-full snap-center snap-normal">
这里是 tab 内容
</div>
))}
</div>

其中父元素的 scroll-snap-type: x mandatory; 表示父容器内的滚动操作(仅限水平方向)应当强制与子元素的某个对齐点对齐

除了 xscroll-snap-type 还可以设置为 y 以应用于垂直滚动,或者 both 来同时应用于水平和垂直滚动

mandatory 相对的还有一个值 proximityproximity 不强制滚动停止时进行对齐,只是在合适时尝试对齐,提供更灵活的用户体验。

子元素设置 scroll-snap-align: center; 来指定这些元素应当在滚动捕捉时居中对齐。也就是说,当用户停止滚动后,这些子元素会自动调整位置,以使其位于滚动容器的中心位置。在实现轮播图、画廊等功能时非常有用,它确保了用户在滑动结束时总能看到完整的项目而不是部分被截断的内容;

scroll-snap-stop: normal 指定元素在滚动捕捉点时应当保持正常的滚动行为。换句话说,当元素滚动接近捕捉点时,滚动不会被强制停止在捕捉点上,而是允许用户在捕捉点周围进行自由的滚动

交互

主要用来处理 tab 页与 tab 按钮之间的联动

  • 页面 => 按钮

使用 Intersection Observer API + useRef + useEffect 实现,注意要给 tab 页面设置 id

export function Tab<T extends React.Key>(props: TabProps<T>) {
const { defaultKey, tabList } = props
const [activeKey, setActiveKey] = useState(defaultKey)
// 使用 ref 维护 observer 实例
const observer = useRef<IntersectionObserver>()
useEffect(() => {
// 初始化 IntersectionObserver 实例,监听元素交叉状态
observer.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// 将交叉的元素设置为当前活动的 key
if (entry.isIntersecting)
setActiveKey(entry.target.id as T)
})
},
// 监听元素可见度达到 50% 时触发回调
{ threshold: 0.5 },
)
// 根据 id 查找并监听每个 tab 元素的交叉情况
tabList.forEach((tab) => {
observer.current?.observe(document.getElementById(tab.key as string)!)
})

// 清理,释放
return () => {
observer.current?.disconnect()
observer.current = undefined
}
}, [])

return (
<div className="...">
<div className="...">
{tabList.map(tab => (
<span
key={tab.key}
className={`${activeKey === tab.key ? '...' : '...'}`}
>
{tab.label1}
<span className={`${tab.className} text-sm`}>{tab.label2}</span>
</span>
))}
</div>
<div className="...">
{tabList.map(tab => (
<div key={tab.key} id={tab.key} className="...">
这里是 tab 内容
</div>
))}
</div>
</div>
)
}
  • 按钮 => 页面

给按钮绑定点击事件,传入 key,根据 id 找到改 tab 元素,执行 scrollIntoView 方法即可

interface TabProps<T extends React.Key> {}

export function Tab<T extends React.Key>(props: TabProps<T>) {
// ...

const handleTabClick = (key: T) => {
// scrollIntoView
document.getElementById(tabList.find(tab => tab.key === key)?.key as string)?.scrollIntoView({ behavior: 'smooth', block: 'end' })
// 同步设置为当前活动的 key
setActiveKey(key)
}

return (
<div className="...">
<div className="...">
{tabList.map(tab => (
<span
key={tab.key}
className={`...`}
onClick={() => handleTabClick(tab.key as T)}
>
{tab.label1}
<span className={`...`}>{tab.label2}</span>
</span>
))}
</div>
<div className="...">
{tabList.map(tab => (
<div key={tab.key} id={tab.key} className="...">
这里是 tab 内容
</div>
))}
</div>
</div>
)
}

至此,完成 Tab 组件的封装