diff options
Diffstat (limited to 'src/components/shared')
43 files changed, 1890 insertions, 390 deletions
diff --git a/src/components/shared/BaseModal.module.scss b/src/components/shared/BaseModal.module.scss index 229b920..0d24a74 100644 --- a/src/components/shared/BaseModal.module.scss +++ b/src/components/shared/BaseModal.module.scss @@ -2,16 +2,10 @@ background-color: rgba(0, 0, 0, 0.6); } .cnt { - position: absolute; background-color: var(--bg-modal); color: var(--color-text); line-height: 1.4; - opacity: 0.6; - transition: all 0.3s ease; - // transform: scale(1.2); - box-shadow: rgba(0, 0, 0, 0.12) 0px 4px 4px, rgba(0, 0, 0, 0.24) 0px 16px 32px; -} -.afterOpen { - opacity: 1; - // transform: scale(1); + box-shadow: + rgba(0, 0, 0, 0.12) 0px 4px 4px, + rgba(0, 0, 0, 0.24) 0px 16px 32px; } diff --git a/src/components/shared/BaseModal.tsx b/src/components/shared/BaseModal.tsx index 4c166db..c9c28b1 100644 --- a/src/components/shared/BaseModal.tsx +++ b/src/components/shared/BaseModal.tsx @@ -1,34 +1,24 @@ import cx from 'clsx'; import * as React from 'react'; -import Modal from '../Modal'; -import modalStyle from '../Modal.module.scss'; - import s from './BaseModal.module.scss'; - -const { useMemo } = React; +import Modal from './Modal'; type BaseModalProps = { isOpen: boolean; onRequestClose: (...args: any[]) => unknown; + title?: string; children: React.ReactNode; }; -export default function BaseModal({ isOpen, onRequestClose, children }: BaseModalProps) { - const className = useMemo( - () => ({ - base: cx(modalStyle.content, s.cnt), - afterOpen: s.afterOpen, - beforeClose: '', - }), - [] - ); +export default function BaseModal({ isOpen, onRequestClose, title, children }: BaseModalProps) { return ( <Modal isOpen={isOpen} onRequestClose={onRequestClose} - className={className} - overlayClassName={cx(modalStyle.overlay, s.overlay)} + title={title} + className={s.cnt} + overlayClassName={cx(s.overlay)} > {children} </Modal> diff --git a/src/components/shared/Basic.module.scss b/src/components/shared/Basic.module.scss index df412e5..cb50df0 100644 --- a/src/components/shared/Basic.module.scss +++ b/src/components/shared/Basic.module.scss @@ -1,20 +1,5 @@ @use '~/styles/utils/custom-media' as *; -h2.sectionNameType { - margin: 0; - font-size: 1em; - @media (--breakpoint-not-small) { - font-size: 1.3em; - } - - span:nth-child(2) { - font-size: 12px; - color: #777; - font-weight: normal; - margin: 0 0.3em; - } -} - @mixin light { --loading-dot-1-1: rgba(0, 0, 0, 0.1); --loading-dot-1-2: rgba(0, 0, 0, 0.5); diff --git a/src/components/shared/Basic.tsx b/src/components/shared/Basic.tsx index 7071938..588fd12 100644 --- a/src/components/shared/Basic.tsx +++ b/src/components/shared/Basic.tsx @@ -2,15 +2,6 @@ import React from 'react'; import s from './Basic.module.scss'; -export function SectionNameType({ name, type }) { - return ( - <h2 className={s.sectionNameType}> - <span style={{ marginRight: 5 }}>{name}</span> - <span>{type}</span> - </h2> - ); -} - export function LoadingDot() { return <span className={s.loadingDot} />; } diff --git a/src/components/shared/Button.module.scss b/src/components/shared/Button.module.scss new file mode 100644 index 0000000..8d2ca46 --- /dev/null +++ b/src/components/shared/Button.module.scss @@ -0,0 +1,74 @@ +@use '~/styles/utils/custom-media' as *; + +.btn { + -webkit-appearance: none; + outline: none; + user-select: none; + position: relative; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + // 与顶栏的 .btnGhost 同一套:卡片色底 + 卡片边框色描边, + // 悬停是蓝描边而不是整块填蓝 + color: var(--color-text); + background: var(--color-card); + border: 1px solid var(--color-card-border); + border-radius: 100px; + transition: + color 0.2s ease, + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease; + + &:focus-visible { + border-color: var(--color-focus-blue); + box-shadow: 0 0 0 3px var(--color-accent-soft-bg); + } + &:hover:not(:disabled) { + color: var(--color-focus-blue); + border-color: var(--color-focus-blue); + } + + font-size: 0.75em; + padding: 4px 7px; + @media (--breakpoint-not-small) { + font-size: small; + padding: 6px 12px; + } + + &.minimal { + border-color: transparent; + background: none; + &:hover:not(:disabled) { + color: var(--color-focus-blue); + background: var(--color-hover-soft); + border-color: transparent; + } + } +} + +.btn:disabled { + opacity: 0.5; +} + +.btnInternal { + display: flex; + align-items: center; + justify-content: center; + column-gap: 4px; +} + +.btnStart { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.loadingContainer { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: inline-flex; +} diff --git a/src/components/shared/Button.tsx b/src/components/shared/Button.tsx new file mode 100644 index 0000000..5ecf029 --- /dev/null +++ b/src/components/shared/Button.tsx @@ -0,0 +1,82 @@ +import cx from 'clsx'; +import * as React from 'react'; + +import { LoadingDot } from './Basic'; +import s0 from './Button.module.scss'; + +const { forwardRef, useCallback } = React; + +type ButtonInternalProps = { + children?: React.ReactNode; + label?: string; + text?: string; + start?: React.ReactNode | (() => React.ReactNode); +}; + +type ButtonProps = { + isLoading?: boolean; + onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown; + disabled?: boolean; + kind?: 'primary' | 'minimal'; + className?: string; + title?: string; +} & ButtonInternalProps; + +function Button(props: ButtonProps, ref: React.Ref<HTMLButtonElement>) { + const { + onClick, + disabled = false, + isLoading, + kind = 'primary', + className, + children, + label, + text, + start, + ...restProps + } = props; + const internalProps = { children, label, text, start }; + const internalOnClick = useCallback( + (e: React.MouseEvent<HTMLButtonElement>) => { + if (isLoading) return; + onClick && onClick(e); + }, + [isLoading, onClick], + ); + const btnClassName = cx(s0.btn, { [s0.minimal]: kind === 'minimal' }, className); + return ( + <button + className={btnClassName} + ref={ref} + onClick={internalOnClick} + disabled={disabled} + {...restProps} + > + {isLoading ? ( + <> + <span style={{ display: 'inline-flex', opacity: 0 }}> + <ButtonInternal {...internalProps} /> + </span> + <span className={s0.loadingContainer}> + <LoadingDot /> + </span> + </> + ) : ( + <ButtonInternal {...internalProps} /> + )} + </button> + ); +} + +function ButtonInternal({ children, label, text, start }: ButtonInternalProps) { + return ( + <div className={s0.btnInternal}> + {start && ( + <span className={s0.btnStart}>{typeof start === 'function' ? start() : start}</span> + )} + {children || label || text} + </div> + ); +} + +export default forwardRef(Button); diff --git a/src/components/shared/Collapsible.tsx b/src/components/shared/Collapsible.tsx new file mode 100644 index 0000000..bc723a0 --- /dev/null +++ b/src/components/shared/Collapsible.tsx @@ -0,0 +1,43 @@ +import { domAnimation, LazyMotion, m } from 'framer-motion'; +import React from 'react'; + +const { memo } = React; + +const variantsCollpapsibleWrap = { + initialOpen: { + height: 'auto', + opacity: 1, + visibility: 'visible', + transition: { duration: 0 }, + }, + open: { + height: 'auto', + opacity: 1, + visibility: 'visible', + transition: { duration: 0.3 }, + }, + closed: { + height: 0, + opacity: 0, + visibility: 'hidden', + overflowY: 'hidden', + transition: { duration: 0.3 }, + }, +}; + +const Collapsible = memo(({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) => { + return ( + <LazyMotion features={domAnimation}> + <m.div + initial={isOpen ? 'initialOpen' : 'closed'} + animate={isOpen ? 'open' : 'closed'} + variants={variantsCollpapsibleWrap} + style={{ overflow: 'hidden' }} + > + {children} + </m.div> + </LazyMotion> + ); +}); + +export default Collapsible; diff --git a/src/components/shared/Fab.module.scss b/src/components/shared/Fab.module.scss deleted file mode 100644 index 61aaecb..0000000 --- a/src/components/shared/Fab.module.scss +++ /dev/null @@ -1,33 +0,0 @@ -.spining { - position: relative; - border-radius: 50%; - background: linear-gradient(60deg, #e66465, #9198e5); - - width: 48px; - height: 48px; - display: flex; - justify-content: center; - align-items: center; -} - -.spining:before { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - border: 2px solid transparent; - border-top-color: currentColor; - border-radius: 50%; - animation: spining_keyframes 1s linear infinite; -} - -@keyframes spining_keyframes { - 0% { - transform: rotate(0); - } - 100% { - transform: rotate(360deg); - } -} diff --git a/src/components/shared/Fab.tsx b/src/components/shared/Fab.tsx deleted file mode 100644 index 49c9a89..0000000 --- a/src/components/shared/Fab.tsx +++ /dev/null @@ -1,155 +0,0 @@ -// adapted from https://github.com/dericgw/react-tiny-fab/blob/master/src/index.tsx -import './rtf.css'; - -import * as React from 'react'; - -import s from './Fab.module.scss'; - -const { useState } = React; - -export function IsFetching({ children }: { children: React.ReactNode }) { - return <span className={s.spining}>{children}</span>; -} - -export const position = { - right: 10, - bottom: 10, -}; - -interface ABProps extends React.HTMLAttributes<HTMLButtonElement> { - text?: string; - onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown; - 'data-testid'?: string; -} - -const AB: React.FC<ABProps> = ({ children, ...p }) => ( - <button type="button" {...p} className="rtf--ab"> - {children} - </button> -); - -interface MBProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, 'tabIndex'> { - tabIndex?: number; -} - -export const MB: React.FC<MBProps> = ({ children, ...p }) => ( - <button type="button" className="rtf--mb" {...p}> - {children} - </button> -); - -const defaultStyles: React.CSSProperties = { bottom: 24, right: 24 }; - -interface FabProps { - event?: 'hover' | 'click'; - style?: React.CSSProperties; - alwaysShowTitle?: boolean; - icon?: React.ReactNode; - mainButtonStyles?: React.CSSProperties; - onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown; - text?: string; - children?: React.ReactNode; -} - -const Fab: React.FC<FabProps> = ({ - event = 'hover', - style = defaultStyles, - alwaysShowTitle = false, - children, - icon, - mainButtonStyles, - onClick, - text, - ...p -}) => { - const [isOpen, setIsOpen] = useState(false); - const ariaHidden = alwaysShowTitle || !isOpen; - const open = () => setIsOpen(true); - const close = () => setIsOpen(false); - const enter = () => event === 'hover' && open(); - const leave = () => event === 'hover' && close(); - const toggle = (e: React.MouseEvent<HTMLButtonElement>) => { - if (onClick) { - return onClick(e); - } - e.persist(); - return event === 'click' ? (isOpen ? close() : open()) : null; - }; - - const actionOnClick = ( - e: React.MouseEvent<HTMLButtonElement>, - userFunc: (e: React.MouseEvent<HTMLButtonElement>) => unknown - ) => { - e.persist(); - setIsOpen(false); - setTimeout(() => { - userFunc(e); - }, 1); - }; - - const rc = () => - React.Children.map(children, (ch, i) => { - if (React.isValidElement<ABProps>(ch)) { - return ( - <li className={`rtf--ab__c ${'top' in style ? 'top' : ''}`}> - {React.cloneElement(ch, { - 'data-testid': `action-button-${i}`, - 'aria-label': ch.props.text || `Menu button ${i + 1}`, - 'aria-hidden': ariaHidden, - tabIndex: isOpen ? 0 : -1, - ...ch.props, - onClick: (e: React.MouseEvent<HTMLButtonElement>) => { - if (ch.props.onClick) actionOnClick(e, ch.props.onClick); - }, - })} - {ch.props.text && ( - <span - className={`${'right' in style ? 'right' : ''} ${ - alwaysShowTitle ? 'always-show' : '' - }`} - aria-hidden={ariaHidden} - > - {ch.props.text} - </span> - )} - </li> - ); - } - return null; - }); - - return ( - <ul - onMouseEnter={enter} - onMouseLeave={leave} - className={`rtf ${isOpen ? 'open' : 'closed'}`} - data-testid="fab" - style={style} - {...p} - > - <li className="rtf--mb__c"> - <MB - onClick={toggle} - style={mainButtonStyles} - data-testid="main-button" - role="button" - aria-label="Floating menu" - tabIndex={0} - > - {icon} - </MB> - {text && ( - <span - className={`${'right' in style ? 'right' : ''} ${alwaysShowTitle ? 'always-show' : ''}`} - aria-hidden={ariaHidden} - > - {text} - </span> - )} - <ul>{rc()}</ul> - </li> - </ul> - ); -}; - -export { Fab, AB as Action }; diff --git a/src/components/shared/FeatherIcons.ts b/src/components/shared/FeatherIcons.ts index ca9c359..b821ecb 100644 --- a/src/components/shared/FeatherIcons.ts +++ b/src/components/shared/FeatherIcons.ts @@ -1,14 +1,17 @@ export { Activity, + AlertCircle, ArrowDown, ArrowDownCircle, ArrowUp, + CheckCircle, ChevronDown, ChevronUp, Cpu, Database, Download, DownloadCloud, + Edit3, Eye, EyeOff, FileText, @@ -26,6 +29,7 @@ export { RefreshCcw, RefreshCw, RotateCw, + Search, Settings, Shield, Sliders, diff --git a/src/components/shared/Head.tsx b/src/components/shared/Head.tsx index 85783c1..aa92443 100644 --- a/src/components/shared/Head.tsx +++ b/src/components/shared/Head.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; -import { connect } from '~/components/StateProvider'; import { getClashAPIConfig, getClashAPIConfigs } from '~/store/app'; +import { connect } from '~/store/StateProvider'; +import type { State } from '~/store/types'; -const mapState = (s) => ({ +const mapState = (s: State) => ({ apiConfig: getClashAPIConfig(s), apiConfigs: getClashAPIConfigs(s), }); diff --git a/src/components/shared/Input.module.scss b/src/components/shared/Input.module.scss new file mode 100644 index 0000000..dfa55b5 --- /dev/null +++ b/src/components/shared/Input.module.scss @@ -0,0 +1,36 @@ +.input { + -webkit-appearance: none; + // 和顶栏搜索框一套:凹槽色垫底 + 卡片边框色描边,垫在卡片上才看得出是输入区 + background-color: var(--color-track); + background-image: none; + border-radius: 8px; + border: 1px solid var(--color-card-border); + box-sizing: border-box; + color: var(--color-text); + display: inline-block; + height: 35px; + outline: none; + padding: 0 15px; + width: 100%; + font-size: small; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; + + &::placeholder { + color: var(--color-text-secondary); + opacity: 0.75; + } +} + +.input:focus { + border-color: var(--color-focus-blue); + // 与开关的聚焦圈同一个 token,别再写死一个对不上的蓝 + box-shadow: 0 0 0 3px var(--color-accent-soft-bg); +} + +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} diff --git a/src/components/shared/Input.tsx b/src/components/shared/Input.tsx new file mode 100644 index 0000000..901aeb5 --- /dev/null +++ b/src/components/shared/Input.tsx @@ -0,0 +1,42 @@ +import cx from 'clsx'; +import React from 'react'; + +import s0 from './Input.module.scss'; + +const { useState, useRef, useEffect, useCallback } = React; + +export default function Input({ + className, + ...props +}: React.InputHTMLAttributes<HTMLInputElement>) { + return <input className={cx(s0.input, className)} {...props} />; +} + +export function SelfControlledInput({ + value, + className, + ...restProps +}: React.InputHTMLAttributes<HTMLInputElement>) { + const [internalValue, setInternalValue] = useState(value); + const refValue = useRef(value); + useEffect(() => { + if (refValue.current !== value) { + // ideally we should only do this when this input is not focused + setInternalValue(value); + } + refValue.current = value; + }, [value]); + const onChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => setInternalValue(e.target.value), + [setInternalValue], + ); + + return ( + <input + className={cx(s0.input, className)} + value={internalValue} + onChange={onChange} + {...restProps} + /> + ); +} diff --git a/src/components/shared/Loading.module.scss b/src/components/shared/Loading.module.scss new file mode 100644 index 0000000..c3f1d16 --- /dev/null +++ b/src/components/shared/Loading.module.scss @@ -0,0 +1,28 @@ +.loading { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.spinner { + width: 20px; + height: 20px; + display: inline-block; + vertical-align: middle; + animation: rotate 1s steps(12, end) infinite; + background: transparent + url('data:image/svg+xml;charset=utf8, %3Csvg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 100 100"%3E%3Cpath fill="none" d="M0 0h100v100H0z"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23E9E9E9" rx="5" ry="5" transform="translate(0 -30)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23989697" rx="5" ry="5" transform="rotate(30 105.98 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%239B999A" rx="5" ry="5" transform="rotate(60 75.98 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23A3A1A2" rx="5" ry="5" transform="rotate(90 65 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23ABA9AA" rx="5" ry="5" transform="rotate(120 58.66 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23B2B2B2" rx="5" ry="5" transform="rotate(150 54.02 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23BAB8B9" rx="5" ry="5" transform="rotate(180 50 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23C2C0C1" rx="5" ry="5" transform="rotate(-150 45.98 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23CBCBCB" rx="5" ry="5" transform="rotate(-120 41.34 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23D2D2D2" rx="5" ry="5" transform="rotate(-90 35 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23DADADA" rx="5" ry="5" transform="rotate(-60 24.02 65)"/%3E%3Crect width="7" height="20" x="46.5" y="40" fill="%23E2E2E2" rx="5" ry="5" transform="rotate(-30 -5.98 65)"/%3E%3C/svg%3E') + no-repeat; + background-size: 100%; +} + +@keyframes rotate { + 0% { + transform: rotate3d(0, 0, 1, 0deg); + } + 100% { + transform: rotate3d(0, 0, 1, 360deg); + } +} diff --git a/src/components/shared/Loading.tsx b/src/components/shared/Loading.tsx new file mode 100644 index 0000000..12ced75 --- /dev/null +++ b/src/components/shared/Loading.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import s from './Loading.module.scss'; + +type Props = { + height?: string; +}; + +const Loading = ({ height }: Props) => { + const style = height ? { height } : {}; + return ( + <div className={s.loading} style={style}> + <div className={s.spinner} /> + </div> + ); +}; + +export default Loading; diff --git a/src/components/shared/Modal.module.scss b/src/components/shared/Modal.module.scss new file mode 100644 index 0000000..e4e6af5 --- /dev/null +++ b/src/components/shared/Modal.module.scss @@ -0,0 +1,52 @@ +@keyframes overlayShow { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes contentShow { + from { + opacity: 0; + scale: 0.96; + } + to { + opacity: 1; + scale: 1; + } +} + +.overlay { + position: fixed; + inset: 0; + background: #444; + z-index: 1024; + + &[data-state='open'] { + animation: overlayShow 0.2s ease; + } +} + +// Radix 的 Content 是 Overlay 的兄弟节点,需自己定位。 +// 这里用独立的 translate / scale 属性而非 transform,避免与各 modal 自带的 transform 冲突。 +.content { + position: fixed; + top: 50%; + left: 50%; + translate: -50% -50%; + z-index: 1025; + max-height: 100vh; + overflow: auto; + outline: none; + color: var(--color-text); + background: var(--bg-modal); + padding: 20px; + border-radius: var(--border-radius); + box-shadow: var(--shadow-card); + + &[data-state='open'] { + animation: contentShow 0.2s ease; + } +} diff --git a/src/components/shared/Modal.tsx b/src/components/shared/Modal.tsx new file mode 100644 index 0000000..b208356 --- /dev/null +++ b/src/components/shared/Modal.tsx @@ -0,0 +1,81 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import cx from 'clsx'; +import * as React from 'react'; + +import s0 from './Modal.module.scss'; + +// react-modal 的 className 支持对象形式;迁移到 Radix 后过渡由 [data-state] 驱动, +// 这里只取 base,afterOpen/beforeClose 已无意义但保留类型以兼容调用方。 +type ClassNameObj = { base?: string; afterOpen?: string; beforeClose?: string }; + +type Props = { + isOpen: boolean; + onRequestClose?: (...args: any[]) => any; + onAfterOpen?: () => void; + className?: string | ClassNameObj; + overlayClassName?: string; + shouldCloseOnOverlayClick?: boolean; + shouldCloseOnEsc?: boolean; + /** 无障碍标题。不传时用视觉隐藏的默认值,避免 Radix 缺少 Title 的告警。 */ + title?: string; + children: React.ReactNode; +}; + +function resolveClassName(className: Props['className']): string | undefined { + if (!className) return undefined; + return typeof className === 'string' ? className : className.base; +} + +function ModalBase({ + isOpen, + onRequestClose, + onAfterOpen, + className, + overlayClassName, + shouldCloseOnOverlayClick = true, + shouldCloseOnEsc = true, + title = 'Dialog', + children, +}: Props) { + const onOpenChange = React.useCallback( + (open: boolean) => { + if (!open) onRequestClose && onRequestClose(); + }, + [onRequestClose], + ); + + // react-modal 的 onAfterOpen 对应 Radix 打开后的自动聚焦时机 + const onOpenAutoFocus = React.useCallback( + (e: Event) => { + if (onAfterOpen) { + e.preventDefault(); + onAfterOpen(); + } + }, + [onAfterOpen], + ); + + return ( + <Dialog.Root open={isOpen} onOpenChange={onOpenChange}> + <Dialog.Portal> + <Dialog.Overlay className={cx(s0.overlay, overlayClassName)} /> + <Dialog.Content + className={cx(s0.content, resolveClassName(className))} + onOpenAutoFocus={onOpenAutoFocus} + onInteractOutside={(e) => { + if (!shouldCloseOnOverlayClick) e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + if (!shouldCloseOnEsc) e.preventDefault(); + }} + aria-describedby={undefined} + > + <Dialog.Title className="visually-hidden">{title}</Dialog.Title> + {children} + </Dialog.Content> + </Dialog.Portal> + </Dialog.Root> + ); +} + +export default React.memo(ModalBase); diff --git a/src/components/shared/PageHeader.module.scss b/src/components/shared/PageHeader.module.scss new file mode 100644 index 0000000..bf2fe3e --- /dev/null +++ b/src/components/shared/PageHeader.module.scss @@ -0,0 +1,400 @@ +/** + * 页面顶栏的统一样式,代理 / 连接 / 规则 / 日志四个页面共用(见 PageHeader.tsx)。 + * + * 断点约定: + * >1024 一行排下:标题 · 分段标签 · 搜索 · 操作钮 + * ≤1024 搜索换到第二行,操作钮靠 margin 顶到第一行右端 + * ≤768 顶栏拉平到页面底色(窄屏顶部已经有一条不透明的导航条, + * 顶栏再铺一层外壳色会夹出第三条色带),按钮退化成正方形图标钮, + * 凹陷的控件(分段标签轨道、输入框)翻成浮起 + */ + +.header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + gap: 12px; + padding: 20px 32px 15px; + // 半透明外壳色 + 毛玻璃 + 下边框:内容从底下滚过去时能透出一点, + // 同时比纯白的卡片灰一档。弹层都 portal 到 body, + // 不受 backdrop-filter 生成包含块的影响 + background: var(--color-chrome); + backdrop-filter: saturate(180%) blur(20px); + border-bottom: 1px solid var(--color-chrome-border); + + @media (max-width: 1024px) { + flex-wrap: wrap; + padding: 16px 16px 12px; + } + + @media (max-width: 768px) { + gap: 8px; + padding: 10px 12px; + background: var(--color-background); + backdrop-filter: none; + } +} + +.title { + margin: 0; + font-size: 1.6rem; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.01em; + color: var(--color-text-highlight); + white-space: nowrap; + + @media (max-width: 1024px) { + font-size: 1.35rem; + } + + @media (max-width: 768px) { + font-size: 1.1rem; + } +} + +/** 窄屏下强制换行的占位,让它后面的东西独占下一行 */ +.rowBreak { + display: none; + + @media (max-width: 768px) { + display: block; + flex-basis: 100%; + height: 0; + } +} + +/* ---------- 分段标签 ---------- */ + +.tabs { + display: flex; + align-items: center; + gap: 2px; + padding: 4px; + border-radius: 12px; + background: var(--color-track); + flex-shrink: 0; + + // 顶栏在窄屏拉平到页面底色后,--color-track 就是底色本身,凹槽会整个消失。 + // 这里去掉凹槽,改成「选中项浮起来」的样式,和导航胶囊、卡片同一套语言 + @media (max-width: 768px) { + padding: 0; + gap: 4px; + background: none; + } +} + +.tab { + display: inline-flex; + align-items: center; + gap: 7px; + appearance: none; + border: none; + background: transparent; + font-family: inherit; + font-size: 0.88rem; + font-weight: 500; + color: var(--color-text-secondary); + padding: 7px 14px; + border-radius: 9px; + cursor: pointer; + white-space: nowrap; + transition: + background-color 0.15s ease, + color 0.15s ease, + box-shadow 0.15s ease; + + &:hover:not(.tabActive) { + color: var(--color-text); + background: var(--color-hover-soft); + } + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: -1px; + } + + @media (max-width: 768px) { + gap: 5px; + padding: 5px 10px; + font-size: 0.8rem; + } +} + +.tabActive { + background: var(--color-card); + color: var(--color-text-highlight); + box-shadow: var(--shadow-segment); + + @media (max-width: 768px) { + border: 1px solid var(--color-card-border); + // 补掉边框占掉的 1px,选中和未选中的高度才对得齐 + padding: 4px 9px; + } +} + +.tabCount { + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 500; + color: var(--color-text-secondary); + + // 小屏上标题 + 标签 + 几个操作钮刚好挤满一行,计数是这里最先能舍的东西 + @media (max-width: 400px) { + display: none; + } +} + +/* ---------- 搜索 ---------- */ + +.search { + position: relative; + display: flex; + align-items: center; + flex: 1 1 240px; + min-width: 160px; + max-width: 340px; + margin-left: auto; + + // 同时覆盖裸 <input> 和 TextFilter 自带的样式,两种用法长得一样 + input { + width: 100%; + height: 38px; + padding: 0 12px 0 38px; + border-radius: 10px; + border: 1px solid var(--color-card-border); + background: var(--color-track); + box-shadow: none; + color: var(--color-text); + font: inherit; + font-size: 0.85rem; + outline: none; + appearance: none; + + &::placeholder { + color: var(--color-text-secondary); + opacity: 0.75; + } + + &:focus { + border-color: var(--color-focus-blue); + } + } + + @media (max-width: 1024px) { + flex-basis: 100%; + min-width: 0; + max-width: none; + margin-left: 0; + } + + @media (max-width: 768px) { + input { + height: 34px; + padding-left: 34px; + font-size: 0.8rem; + // 同分段标签:底色拉平后凹陷的输入框会看不见,改成和卡片同层浮起 + background: var(--color-card); + } + } +} + +/** + * 搜索旁边还要并排放别的控件(下拉筛选)时叠这个类: + * 窄屏下别独占整行,把剩下的宽度让出来 + */ +.searchInline { + @media (max-width: 768px) { + flex: 1 1 auto; + flex-basis: auto; + min-width: 0; + } +} + +.searchIcon { + position: absolute; + left: 13px; + color: var(--color-text-secondary); + pointer-events: none; + + @media (max-width: 768px) { + left: 11px; + } +} + +/* ---------- 下拉筛选 ---------- */ + +// 叠在 Select 组件自带样式之上,把它拉齐到顶栏这套尺寸。 +// 类名写两遍是为了翻倍特异性——Select 自己的 .select 也是单类, +// 靠打包顺序决定胜负太脆 +.select.select { + height: 38px; + width: auto; + flex-shrink: 0; + border-radius: 10px; + border-color: var(--color-card-border); + background-color: var(--color-track); + box-shadow: none; + font-size: 0.85rem; + + @media (max-width: 768px) { + height: 34px; + font-size: 0.8rem; + background-color: var(--color-card); + } +} + +/* ---------- 操作按钮 ---------- */ + +.actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + + // 搜索换到第二行后,靠 margin 把操作钮顶到第一行右端 + @media (max-width: 1024px) { + margin-left: auto; + } + + @media (max-width: 768px) { + gap: 6px; + } +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + appearance: none; + height: 38px; + padding: 0 15px; + border-radius: 10px; + border: 1px solid transparent; + font-family: inherit; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + color 0.15s ease, + opacity 0.15s ease; + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: 2px; + } + + // 窄屏下文字全部收起,按钮退化成正方形图标钮 + @media (max-width: 768px) { + gap: 0; + width: 34px; + height: 34px; + padding: 0; + justify-content: center; + } +} + +.btnGhost { + background: var(--color-card); + border-color: var(--color-card-border); + color: var(--color-text); + + &:hover { + border-color: var(--color-focus-blue); + color: var(--color-focus-blue); + } +} + +.btnPrimary { + background: var(--color-focus-blue); + color: #fff; + + &:hover:not(:disabled) { + filter: brightness(1.08); + } + + &:disabled { + cursor: default; + } +} + +.btnDanger { + background: var(--color-danger); + color: #fff; + + &:hover { + filter: brightness(1.08); + } +} + +.btnPaused { + background: var(--color-warn-soft-bg); + color: var(--color-warn); + border-color: transparent; +} + +.btnBusy { + opacity: 0.75; +} + +/** 中等宽度就收起的文字,让给更重要的按钮 */ +.btnText { + @media (max-width: 1200px) { + display: none; + } +} + +/** 比 .btnText 保留得久一些:只有窄屏才收起 */ +.btnTextSm { + @media (max-width: 768px) { + display: none; + } +} + +.iconBtn { + display: inline-flex; + align-items: center; + justify-content: center; + appearance: none; + width: 38px; + height: 38px; + border-radius: 10px; + border: 1px solid var(--color-card-border); + background: var(--color-card); + color: var(--color-text-secondary); + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; + + &:hover { + border-color: var(--color-focus-blue); + color: var(--color-focus-blue); + } + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: 2px; + } + + @media (max-width: 768px) { + width: 34px; + height: 34px; + } +} + +.iconBtnActive { + background: var(--color-focus-blue); + border-color: var(--color-focus-blue); + color: #fff; + + &:hover { + color: #fff; + } +} diff --git a/src/components/shared/PageHeader.tsx b/src/components/shared/PageHeader.tsx new file mode 100644 index 0000000..69cb7ab --- /dev/null +++ b/src/components/shared/PageHeader.tsx @@ -0,0 +1,206 @@ +import cx from 'clsx'; +import * as React from 'react'; + +import { Search } from '~/components/shared/FeatherIcons'; + +import s from './PageHeader.module.scss'; + +/** + * 页面顶栏的一套零件,代理 / 连接 / 规则 / 日志四个页面共用, + * 样式和断点行为全在 PageHeader.module.scss 里。 + * + * 每个零件都接 className,页面自己的模块可以往上叠特有的东西 + * (比如连接页窄屏下要重排 order)。 + */ + +export function PageHeader({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return <header className={cx(s.header, className)}>{children}</header>; +} + +export function HeaderTitle({ children }: { children: React.ReactNode }) { + return <h1 className={s.title}>{children}</h1>; +} + +/** 窄屏下强制换行,让它后面的东西独占下一行 */ +export function HeaderRowBreak({ className }: { className?: string }) { + return <span className={cx(s.rowBreak, className)} aria-hidden />; +} + +export function HeaderTabs({ + label, + children, + className, +}: { + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( + <div className={cx(s.tabs, className)} role="tablist" aria-label={label}> + {children} + </div> + ); +} + +/** 计数超过三位就收成 999+,否则标签宽度会跟着数字跳 */ +function formatQty(n: number) { + return n < 1000 ? String(n) : '999+'; +} + +export function HeaderTab({ + active, + label, + count, + onClick, +}: { + active: boolean; + label: string; + /** 省略则不显示计数 */ + count?: number; + onClick: () => void; +}) { + return ( + <button + type="button" + role="tab" + aria-selected={active} + className={cx(s.tab, { [s.tabActive]: active })} + onClick={onClick} + > + {label} + {typeof count === 'number' ? <span className={s.tabCount}>{formatQty(count)}</span> : null} + </button> + ); +} + +/** + * 搜索框。传 value/onChange 就是受控输入,传 children 则由调用方 + * 自己塞输入组件(比如挂在 jotai atom 上的 TextFilter)。 + */ +export function HeaderSearch({ + placeholder, + value, + onChange, + children, + className, +}: { + placeholder?: string; + value?: string; + onChange?: (value: string) => void; + children?: React.ReactNode; + className?: string; +}) { + return ( + <div className={cx(s.search, className)}> + <Search size={15} className={s.searchIcon} aria-hidden /> + {children ?? ( + <input + type="text" + name="filter" + autoComplete="off" + value={value} + placeholder={placeholder} + onChange={(e) => onChange?.(e.target.value)} + /> + )} + </div> + ); +} + +export function HeaderActions({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return <div className={cx(s.actions, className)}>{children}</div>; +} + +type ButtonVariant = 'ghost' | 'primary' | 'danger' | 'paused'; + +const variantClass: Record<ButtonVariant, string> = { + ghost: s.btnGhost, + primary: s.btnPrimary, + danger: s.btnDanger, + paused: s.btnPaused, +}; + +export function HeaderButton({ + variant = 'ghost', + icon, + label, + /** 悬停提示,省略则用 label。展开说明按钮作用时才需要单独给 */ + title, + /** + * 文字收起的宽度门槛。'sm' 只在窄屏收(默认,适合主要操作), + * 'md' 中等宽度就收(次要操作,给主要操作腾地方) + */ + hideLabelAt = 'sm', + busy, + disabled, + onClick, +}: { + variant?: ButtonVariant; + icon: React.ReactNode; + label: string; + title?: string; + hideLabelAt?: 'sm' | 'md'; + busy?: boolean; + disabled?: boolean; + onClick: () => void; +}) { + return ( + <button + type="button" + className={cx(s.btn, variantClass[variant], { [s.btnBusy]: busy })} + onClick={onClick} + disabled={disabled} + // 文字会在窄屏被藏掉,读屏和 tooltip 都得靠这两个属性兜住 + aria-label={label} + title={title ?? label} + > + {icon} + <span className={hideLabelAt === 'md' ? s.btnText : s.btnTextSm}>{label}</span> + </button> + ); +} + +export function HeaderIconButton({ + icon, + label, + active, + expanded, + onClick, +}: { + icon: React.ReactNode; + label: string; + active?: boolean; + expanded?: boolean; + onClick: () => void; +}) { + return ( + <button + type="button" + className={cx(s.iconBtn, { [s.iconBtnActive]: active })} + onClick={onClick} + aria-label={label} + aria-expanded={expanded} + title={label} + > + {icon} + </button> + ); +} + +/** 顶栏里的 <Select> 要叠的类名,把它拉齐到按钮那套尺寸 */ +export const headerSelectClass = s.select; + +/** 搜索旁边还并排放了别的控件时,叠在 HeaderSearch 上 */ +export const headerSearchInlineClass = s.searchInline; diff --git a/src/components/shared/Popover.module.scss b/src/components/shared/Popover.module.scss new file mode 100644 index 0000000..78d02ed --- /dev/null +++ b/src/components/shared/Popover.module.scss @@ -0,0 +1,43 @@ +.anchor { + position: relative; + display: inline-flex; +} + +.panel { + position: fixed; + z-index: 30; + background: var(--color-card); + border: 1px solid var(--color-card-border); + border-radius: 14px; + box-shadow: var(--shadow-popover); + padding: 14px 16px; + overflow-y: auto; + // 预留滚动条宽度,否则纵向滚动条出现时会挤掉内容宽度、逼出一条横向滚动条 + scrollbar-gutter: stable; + overscroll-behavior: contain; + animation: popoverIn 0.14s ease-out; +} + +/* 首帧先渲染出来量宽高,定位算完之前不要让用户看到 */ +.measuring { + top: 0; + left: 0; + visibility: hidden; +} + +@keyframes popoverIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .panel { + animation: none; + } +} diff --git a/src/components/shared/Popover.tsx b/src/components/shared/Popover.tsx new file mode 100644 index 0000000..f21bd90 --- /dev/null +++ b/src/components/shared/Popover.tsx @@ -0,0 +1,127 @@ +import cx from 'clsx'; +import * as React from 'react'; +import { createPortal } from 'react-dom'; + +import s from './Popover.module.scss'; + +const { useCallback, useEffect, useLayoutEffect, useRef, useState } = React; + +// 弹层与视口边缘的最小间距 +const VIEWPORT_MARGIN = 12; +// 弹层与触发器的间距 +const ANCHOR_GAP = 8; + +type Position = { top: number; left: number; maxWidth: number; maxHeight: number }; + +type Props = { + isOpen: boolean; + onClose: () => void; + /** 触发器,点击它不会触发「点击外部关闭」 */ + trigger: React.ReactNode; + children: React.ReactNode; + /** 面板与触发器的对齐边 */ + align?: 'left' | 'right'; + label?: string; +}; + +/** + * 锚定弹层,点击外部或按 Esc 关闭。 + * + * 面板通过 portal 挂到 body 上而不是留在触发器里:页面滚动容器 `.content` 是 + * `overflow-x: auto`,绝对定位的面板会被它裁掉;而顶栏的 `backdrop-filter` 又会 + * 成为 fixed 定位的包含块,所以 fixed 也救不了。挂到 body 上再按触发器的位置 + * 算坐标,顺便把面板夹在视口内,窄屏下就不会溢出到屏幕外。 + */ +export function Popover({ isOpen, onClose, trigger, children, align = 'right', label }: Props) { + const anchorRef = useRef<HTMLDivElement>(null); + const panelRef = useRef<HTMLDivElement>(null); + const [position, setPosition] = useState<Position | null>(null); + + const updatePosition = useCallback(() => { + const anchor = anchorRef.current?.getBoundingClientRect(); + const panel = panelRef.current; + if (!anchor || !panel) return; + + const vw = document.documentElement.clientWidth; + const vh = document.documentElement.clientHeight; + const maxWidth = vw - VIEWPORT_MARGIN * 2; + const width = Math.min(panel.offsetWidth, maxWidth); + + const preferredLeft = align === 'right' ? anchor.right - width : anchor.left; + // 夹在视口内,窄屏上触发器靠中间时也不会溢出到屏幕外 + const left = Math.min(Math.max(VIEWPORT_MARGIN, preferredLeft), vw - VIEWPORT_MARGIN - width); + const top = anchor.bottom + ANCHOR_GAP; + + setPosition({ + top, + left, + maxWidth, + maxHeight: Math.max(160, vh - top - VIEWPORT_MARGIN), + }); + }, [align]); + + // 定位要在绘制前完成,否则会看到面板从左上角跳过来 + useLayoutEffect(() => { + if (!isOpen) { + setPosition(null); + return; + } + updatePosition(); + }, [isOpen, updatePosition]); + + useEffect(() => { + if (!isOpen) return; + + const onPointerDown = (e: MouseEvent | TouchEvent) => { + const target = e.target as Node; + if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) return; + onClose(); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('touchstart', onPointerDown); + document.addEventListener('keydown', onKeyDown); + window.addEventListener('resize', updatePosition); + // 捕获阶段,这样内层滚动容器(.content)滚动时也能跟着动 + window.addEventListener('scroll', updatePosition, true); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('touchstart', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [isOpen, onClose, updatePosition]); + + return ( + <div className={s.anchor} ref={anchorRef}> + {trigger} + {isOpen + ? createPortal( + <div + ref={panelRef} + className={cx(s.panel, { [s.measuring]: position === null })} + role="dialog" + aria-label={label} + style={ + position + ? { + top: position.top, + left: position.left, + maxWidth: position.maxWidth, + maxHeight: position.maxHeight, + } + : undefined + } + > + {children} + </div>, + document.body, + ) + : null} + </div> + ); +} diff --git a/src/components/shared/SegmentedControl.module.scss b/src/components/shared/SegmentedControl.module.scss new file mode 100644 index 0000000..522a592 --- /dev/null +++ b/src/components/shared/SegmentedControl.module.scss @@ -0,0 +1,50 @@ +.track { + display: flex; + align-items: center; + gap: 2px; + padding: 3px; + min-width: 0; + border-radius: 10px; + background: var(--color-track); + border: 1px solid var(--color-card-border); +} + +.segment { + flex: 1; + min-width: 0; + appearance: none; + border: none; + background: transparent; + color: var(--color-text-secondary); + font-family: inherit; + font-size: 0.8rem; + font-weight: 500; + line-height: 1; + padding: 7px 10px; + border-radius: 8px; + cursor: pointer; + // 档位多时宁可截断文字,也不要把弹层顶出横向滚动条 + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: + background-color 0.15s ease, + color 0.15s ease, + box-shadow 0.15s ease; + + &:hover:not(.selected) { + color: var(--color-text); + background: var(--color-hover-soft); + } + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: -1px; + } +} + +.selected { + background: var(--color-card); + color: var(--color-text-highlight); + box-shadow: var(--shadow-segment); +} diff --git a/src/components/shared/SegmentedControl.tsx b/src/components/shared/SegmentedControl.tsx new file mode 100644 index 0000000..93ff64e --- /dev/null +++ b/src/components/shared/SegmentedControl.tsx @@ -0,0 +1,48 @@ +import cx from 'clsx'; +import * as React from 'react'; + +import s from './SegmentedControl.module.scss'; + +export type SegmentedOption<T extends string | number> = { + value: T; + label: React.ReactNode; + title?: string; +}; + +type Props<T extends string | number> = { + options: SegmentedOption<T>[]; + value: T; + onChange: (value: T) => void; + label?: string; + className?: string; +}; + +/** 分段选择器:一条轨道内若干选项,选中项以白色药丸高亮 */ +export function SegmentedControl<T extends string | number>({ + options, + value, + onChange, + label, + className, +}: Props<T>) { + return ( + <div className={cx(s.track, className)} role="radiogroup" aria-label={label}> + {options.map((o) => { + const selected = o.value === value; + return ( + <button + key={o.value} + type="button" + role="radio" + aria-checked={selected} + title={o.title} + className={cx(s.segment, { [s.selected]: selected })} + onClick={() => onChange(o.value)} + > + {o.label} + </button> + ); + })} + </div> + ); +} diff --git a/src/components/shared/Select.module.scss b/src/components/shared/Select.module.scss index 1c42c60..ac54bd6 100644 --- a/src/components/shared/Select.module.scss +++ b/src/components/shared/Select.module.scss @@ -5,12 +5,13 @@ font-size: 0.95em; padding-left: 14px; appearance: none; - background-color: var(--color-input-bg); + // 和输入框一套凹槽色 + 卡片边框色,别再用只有自己在用的 --color-input-bg + background-color: var(--color-track); color: var(--color-text); padding-right: 34px; border-radius: 8px; - border: 1px solid transparent; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); + border: 1px solid var(--color-card-border); + box-shadow: none; background-image: url(data:image/svg+xml,%0A%20%20%20%20%3Csvg%20width%3D%228%22%20height%3D%2224%22%20viewBox%3D%220%200%208%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%207L7%2011H1L4%207Z%22%20fill%3D%22%23999999%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%2017L1%2013L7%2013L4%2017Z%22%20fill%3D%22%23999999%22%20%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E%0A%20%20); background-position: right 12px center; background-repeat: no-repeat; @@ -22,11 +23,10 @@ border-color: var(--color-focus-blue); outline: none !important; color: var(--color-text-highlight); - transform: translateY(-1px); } .select:focus { - transform: translateY(0); - box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.15); + // 与输入框、开关的聚焦圈同一个 token + box-shadow: 0 0 0 3px var(--color-accent-soft-bg); } .select option { diff --git a/src/components/shared/Select.tsx b/src/components/shared/Select.tsx index 70e1924..8e67a01 100644 --- a/src/components/shared/Select.tsx +++ b/src/components/shared/Select.tsx @@ -5,13 +5,17 @@ import s from './Select.module.scss'; type Props = { options: Array<string[]>; - selected: string; + selected: string | undefined; } & React.SelectHTMLAttributes<HTMLSelectElement>; export default function Select({ options, selected, onChange, className, ...props }: Props) { return ( - - <select className={cx(s.select, className)} value={selected} onChange={onChange} {...props}> + <select + className={cx(s.select, className)} + value={selected ?? ''} + onChange={onChange} + {...props} + > {options.map(([value, name]) => ( <option key={value} value={value}> {name} diff --git a/src/components/shared/Selection.module.scss b/src/components/shared/Selection.module.scss new file mode 100644 index 0000000..44cf4d8 --- /dev/null +++ b/src/components/shared/Selection.module.scss @@ -0,0 +1,23 @@ +.fieldset { + margin: 0; + padding: 0; + border: 0; + display: flex; + flex-wrap: wrap; + flex-direction: row; +} + +.input + .cnt { + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + margin-bottom: 5px; +} + +.input:focus + .cnt { + border-color: var(--color-focus-blue); +} + +.input:checked + .cnt { + border-color: var(--color-focus-blue); +} diff --git a/src/components/shared/Selection.tsx b/src/components/shared/Selection.tsx new file mode 100644 index 0000000..9cabb6a --- /dev/null +++ b/src/components/shared/Selection.tsx @@ -0,0 +1,45 @@ +import cx from 'clsx'; +import React from 'react'; + +import s from './Selection.module.scss'; + +type SelectionProps = { + OptionComponent: (...args: any[]) => any; + optionPropsList: any[]; + selectedIndex: number; + onChange: (value: string) => void; +}; + +export function Selection2({ + OptionComponent, + optionPropsList, + selectedIndex, + onChange, +}: SelectionProps) { + const inputCx = cx('visually-hidden', s.input); + const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + onChange(e.target.value); + }; + return ( + <fieldset className={s.fieldset}> + {optionPropsList.map((props, idx) => { + return ( + <label key={idx}> + <input + type="radio" + checked={selectedIndex === idx} + name="selection" + value={idx} + aria-labelledby={'traffic chart type ' + idx} + onChange={onInputChange} + className={inputCx} + /> + <div className={s.cnt}> + <OptionComponent {...props} /> + </div> + </label> + ); + })} + </fieldset> + ); +} diff --git a/src/components/shared/Sparkline.module.scss b/src/components/shared/Sparkline.module.scss deleted file mode 100644 index bc60060..0000000 --- a/src/components/shared/Sparkline.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -.sparkline { - width: 100%; - height: 10vh; - margin-top: auto; -} diff --git a/src/components/shared/Sparkline.tsx b/src/components/shared/Sparkline.tsx deleted file mode 100644 index bcb96f8..0000000 --- a/src/components/shared/Sparkline.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import * as React from 'react'; -import { Line } from 'react-chartjs-2'; - -import { chartJSResource, chartStyles, commonDataSetProps } from '~/misc/chart'; -import prettyBytes from '~/misc/pretty-bytes'; - -import s from './Sparkline.module.scss'; - -const { useMemo } = React; - -const extraChartOptions: any = { - responsive: true, - maintainAspectRatio: false, - parsing: false, - animation: { - duration: 1000, - easing: 'linear', - }, - animations: { - y: { - duration: 0, - }, - x: { - duration: 0, - }, - }, - transitions: { - active: { - animation: { - duration: 0, - }, - }, - }, - plugins: { - legend: { display: false }, - tooltip: { - enabled: true, - intersect: false, - mode: 'index', - }, - }, - scales: { - x: { - type: 'time', - display: false, - }, - y: { - display: false, - beginAtZero: true, - }, - }, - elements: { - line: { - borderWidth: 1, - tension: 0.4, - }, - point: { - radius: 0, - }, - }, -}; - -export default function Sparkline({ data: dataArray, labels, type, styleIndex = 0 }) { - chartJSResource.read(); - - const isMemory = type === 'inuse'; - - const options = useMemo(() => { - return { - ...extraChartOptions, - scales: { - ...extraChartOptions.scales, - y: { - display: false, - // 内存值稳定,不从零开始,让 Y 轴自动适应数据范围以显示波动 - beginAtZero: !isMemory, - }, - }, - plugins: { - ...extraChartOptions.plugins, - tooltip: { - ...extraChartOptions.plugins.tooltip, - displayColors: false, - callbacks: { - title: () => '', - label(context) { - if (context.parsed.y !== null) { - const suffix = isMemory ? '' : '/s'; - const raw = isMemory ? context.parsed.y : Math.expm1(context.parsed.y); - return prettyBytes(raw) + suffix; - } - return ''; - }, - }, - }, - }, - }; - }, [type, isMemory]); - - const data = useMemo( - () => ({ - datasets: [ - { - ...commonDataSetProps, - ...chartStyles[styleIndex][type], - // 内存用原始值(变化幅度小,不需要压缩);流量用 log1p 压缩尖刺 - data: dataArray.map((v, i) => ({ x: labels[i], y: isMemory ? v : Math.log1p(v) })), - fill: true, - }, - ], - }), - [dataArray, labels, type, styleIndex, isMemory], - ); - - return ( - <div className={s.sparkline}> - <Line data={data} options={options} redraw={false} /> - </div> - ); -} diff --git a/src/components/shared/SvgGithub.tsx b/src/components/shared/SvgGithub.tsx new file mode 100644 index 0000000..45828c2 --- /dev/null +++ b/src/components/shared/SvgGithub.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +type Props = { + width?: number; + height?: number; +}; + +export default function SvgGithub({ width = 24, height = 24 }: Props = {}) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={width} + height={height} + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" /> + </svg> + ); +} diff --git a/src/components/shared/SvgYacd.module.scss b/src/components/shared/SvgYacd.module.scss new file mode 100644 index 0000000..f668137 --- /dev/null +++ b/src/components/shared/SvgYacd.module.scss @@ -0,0 +1,14 @@ +.path { + stroke-dasharray: 890; + stroke-dashoffset: 890; + animation: dash 3s ease-in-out forwards normal infinite; +} + +@keyframes dash { + from { + stroke-dashoffset: 890; + } + to { + stroke-dashoffset: 0; + } +} diff --git a/src/components/shared/SvgYacd.tsx b/src/components/shared/SvgYacd.tsx new file mode 100644 index 0000000..d7c1b2f --- /dev/null +++ b/src/components/shared/SvgYacd.tsx @@ -0,0 +1,92 @@ +import cx from 'clsx'; +import * as React from 'react'; + +import s from './SvgYacd.module.scss'; + +type Props = { + width?: number; + height?: number; + animate?: boolean; + c0?: string; + c1?: string; + stroke?: string; + eye?: string; + line?: string; +}; + +function SvgYacd({ + width = 320, + height = 320, + animate = false, + c0 = '#316eb5', + c1 = '#f19500', + line = '#cccccc', +}: Props) { + const faceClasName = cx({ [s.path]: animate }); + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + version="1.2" + viewBox="0 0 512 512" + width={width} + height={height} + > + <path + id="Layer" + className={faceClasName} + fill={c0} + stroke={line} + strokeLinecap="round" + strokeWidth="4" + d="m280.8 182.4l119-108.3c1.9-1.7 4.3-2.7 6.8-2.4l39.5 4.1c2.1 0.3 3.9 2.2 3.9 4.4v251.1c0 2-1.5 3.9-3.5 4.4l-41.9 9c-0.5 0.3-1.2 0.3-1.9 0.3h-18.8c-2.4 0-4.4-2-4.4-4.4v-132.9c0-7.5-9-11.7-14.8-6.3l-59 53.4c-2.2 2.2-5.4 2.9-8.5 1.9-27.1-8-56.3-8-83.4 0-2.9 1-6.1 0.3-8.5-1.9l-59-53.4c-5.6-5.4-14.6-1.2-14.6 6.3v132.9c0 2.4-2.2 4.4-4.7 4.4h-18.7c-0.7 0-1.2 0-2-0.3l-41.6-9c-2-0.5-3.5-2.4-3.5-4.4v-251.1c0-2.2 1.8-4.1 3.9-4.4l39.5-4.1c2.5-0.3 4.9 0.7 6.9 2.4l115.7 105.3c2 1.7 4.6 2.5 7.1 2.2 15.3-2.2 31.4-1.9 46.5 0.8z" + /> + <path + id="Layer" + className={faceClasName} + fill={c0} + stroke={line} + strokeLinecap="round" + strokeWidth="4" + d="m269.4 361.8l-7.1 13.4c-2.4 4.2-8.5 4.2-11 0l-7-13.4c-2.5-4.1 0.7-9.3 5.3-9h14.4c4.9 0 7.8 4.9 5.4 9z" + /> + <path + id="Layer" + className={faceClasName} + fill={c1} + stroke={line} + strokeLinecap="round" + strokeWidth="4" + d="m160.7 362.5c3.6 0 6.8 3.2 6.8 6.9 0 3.6-3.2 6.5-6.8 6.5h-94.6c-3.6 0-6.8-2.9-6.8-6.5 0-3.7 3.2-6.9 6.8-6.9z" + /> + <path + id="Layer" + className={faceClasName} + fill={c1} + stroke={line} + strokeLinecap="round" + strokeWidth="4" + d="m158.7 394.7c3.4-1 7.1 1 8.3 4.4 1 3.4-1 7.3-4.4 8.3l-92.8 31.7c-3.4 1.2-7.3-0.7-8.3-4.2-1.2-3.6 0.7-7.3 4.4-8.5z" + /> + <path + id="Layer" + className={faceClasName} + fill={c1} + stroke={line} + strokeLinecap="round" + strokeWidth="4" + d="m446.1 426.4c3.4 1.2 5.3 4.9 4.3 8.5-1.2 3.5-4.8 5.4-8.2 4.2l-93.1-31.7c-3.5-1-5.4-4.9-4.2-8.3 1-3.4 4.9-5.4 8.3-4.4z" + /> + <path + id="Layer" + className={faceClasName} + fill={c1} + stroke={line} + strokeLinecap="round" + strokeWidth="4" + d="m445.8 362.5c3.7 0 6.6 3.2 6.6 6.9 0 3.6-2.9 6.5-6.6 6.5h-94.8c-3.6 0-6.6-2.9-6.6-6.5 0-3.7 3-6.9 6.6-6.9z" + /> + </svg> + ); +} + +export default SvgYacd; diff --git a/src/components/shared/SwitchThemed.module.scss b/src/components/shared/SwitchThemed.module.scss new file mode 100644 index 0000000..26edc6c --- /dev/null +++ b/src/components/shared/SwitchThemed.module.scss @@ -0,0 +1,56 @@ +// 尺寸与迁移前的 react-switch 保持一致:轨道 44x28,滑块 24 +.root { + --switch-w: 44px; + --switch-h: 28px; + --switch-thumb: 24px; + + position: relative; + flex-shrink: 0; + width: var(--switch-w); + height: var(--switch-h); + padding: 0; + border: none; + border-radius: calc(var(--switch-h) / 2); + background-color: var(--color-toggle-bg); + cursor: pointer; + transition: background-color 0.2s ease; + -webkit-tap-highlight-color: transparent; + + &[data-state='checked'] { + background-color: var(--color-focus-blue); + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--color-accent-soft-bg); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.mini { + --switch-w: 34px; + --switch-h: 20px; + --switch-thumb: 16px; +} + +.thumb { + display: block; + width: var(--switch-thumb); + height: var(--switch-thumb); + border-radius: 50%; + background-color: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + transition: transform 0.2s ease; + transform: translateX(calc((var(--switch-h) - var(--switch-thumb)) / 2)); + will-change: transform; + + &[data-state='checked'] { + transform: translateX( + calc(var(--switch-w) - var(--switch-thumb) - (var(--switch-h) - var(--switch-thumb)) / 2) + ); + } +} diff --git a/src/components/shared/SwitchThemed.tsx b/src/components/shared/SwitchThemed.tsx new file mode 100644 index 0000000..61e91e9 --- /dev/null +++ b/src/components/shared/SwitchThemed.tsx @@ -0,0 +1,33 @@ +import * as Switch from '@radix-ui/react-switch'; +import cx from 'clsx'; +import * as React from 'react'; + +import s from './SwitchThemed.module.scss'; + +type Props = { + checked?: boolean; + onChange?: (checked: boolean) => void; + name?: string; + disabled?: boolean; + size?: 'default' | 'mini'; +}; + +export default function SwitchThemed({ + checked = false, + onChange, + name, + disabled, + size = 'default', +}: Props) { + return ( + <Switch.Root + className={cx(s.root, { [s.mini]: size === 'mini' })} + checked={checked} + onCheckedChange={onChange} + name={name} + disabled={disabled} + > + <Switch.Thumb className={s.thumb} /> + </Switch.Root> + ); +} diff --git a/src/components/shared/TextFitler.module.scss b/src/components/shared/TextFilter.module.scss index 3977aad..3977aad 100644 --- a/src/components/shared/TextFitler.module.scss +++ b/src/components/shared/TextFilter.module.scss diff --git a/src/components/shared/TextFitler.tsx b/src/components/shared/TextFilter.tsx index 567efbc..79c2cfb 100644 --- a/src/components/shared/TextFitler.tsx +++ b/src/components/shared/TextFilter.tsx @@ -1,14 +1,12 @@ +import type { PrimitiveAtom } from 'jotai'; import * as React from 'react'; +import { useTextInput } from '~/hooks/useTextInput'; -import { useTextInut } from '~/hooks/useTextInput'; - -import s from './TextFitler.module.scss'; - -import type { PrimitiveAtom } from 'jotai'; +import s from './TextFilter.module.scss'; export function TextFilter(props: { textAtom: PrimitiveAtom<string>; placeholder?: string }) { - const [onChange, text] = useTextInut(props.textAtom); + const [onChange, text] = useTextInput(props.textAtom); return ( <input className={s.input} diff --git a/src/components/shared/ThemeSwitcher.tsx b/src/components/shared/ThemeSwitcher.tsx index 59c4c3a..4a510d5 100644 --- a/src/components/shared/ThemeSwitcher.tsx +++ b/src/components/shared/ThemeSwitcher.tsx @@ -1,15 +1,15 @@ -import { LazyMotion, domAnimation, m } from 'framer-motion'; +import { domAnimation, LazyMotion, m } from 'framer-motion'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { Tooltip } from '~/components/shared/Tooltip'; -import { connect } from '~/components/StateProvider'; import { getTheme, switchTheme } from '~/store/app'; -import { State } from '~/store/types'; +import { connect } from '~/store/StateProvider'; +import { DispatchFn, State } from '~/store/types'; import s from './ThemeSwitcher.module.scss'; -export function ThemeSwitcherImpl({ theme, dispatch }) { +export function ThemeSwitcherImpl({ theme, dispatch }: { theme: string; dispatch: DispatchFn }) { const { t } = useTranslation(); const themeIcon = React.useMemo(() => { diff --git a/src/components/shared/Toast.module.scss b/src/components/shared/Toast.module.scss new file mode 100644 index 0000000..d5ba905 --- /dev/null +++ b/src/components/shared/Toast.module.scss @@ -0,0 +1,87 @@ +.container { + position: fixed; + z-index: 1000; + right: 16px; + bottom: 16px; + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-end; + pointer-events: none; + + @media (max-width: 768px) { + right: 10px; + left: 10px; + bottom: 10px; + align-items: stretch; + } +} + +.toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: 10px; + max-width: 420px; + padding: 12px 14px; + border-radius: 10px; + background: var(--color-card); + border: 1px solid var(--color-card-border); + box-shadow: var(--shadow-popover); + color: var(--color-text); + font-size: 0.9rem; + line-height: 1.45; + animation: slide-in 0.18s ease-out; + + @media (max-width: 768px) { + max-width: none; + } +} + +.icon { + flex-shrink: 0; + display: flex; + padding-top: 1px; +} + +.success .icon { + color: var(--color-success); +} + +.error .icon { + color: var(--color-danger); +} + +.info .icon { + color: var(--color-focus-blue); +} + +.message { + flex: 1; + word-break: break-word; +} + +.close { + flex-shrink: 0; + display: flex; + padding: 0; + border: 0; + background: none; + cursor: pointer; + color: var(--color-text-secondary); + + &:hover { + color: var(--color-text-highlight); + } +} + +@keyframes slide-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/components/shared/Toast.tsx b/src/components/shared/Toast.tsx new file mode 100644 index 0000000..4d0140e --- /dev/null +++ b/src/components/shared/Toast.tsx @@ -0,0 +1,40 @@ +import cx from 'clsx'; +import { useAtomValue } from 'jotai'; +import { createPortal } from 'react-dom'; + +import { AlertCircle, CheckCircle, Info, X } from '~/components/shared/FeatherIcons'; +import { dismissToast, toastsAtom, type ToastKind } from '~/store/toast'; + +import s from './Toast.module.scss'; + +const ICONS: Record<ToastKind, typeof Info> = { + success: CheckCircle, + error: AlertCircle, + info: Info, +}; + +export function Toaster() { + const toasts = useAtomValue(toastsAtom); + + if (toasts.length === 0) return null; + + return createPortal( + <div className={s.container} role="region" aria-live="polite"> + {toasts.map(({ id, kind, message }) => { + const Icon = ICONS[kind]; + return ( + <div key={id} className={cx(s.toast, s[kind])}> + <span className={s.icon}> + <Icon size={18} /> + </span> + <span className={s.message}>{message}</span> + <button className={s.close} onClick={() => dismissToast(id)} aria-label="Close"> + <X size={16} /> + </button> + </div> + ); + })} + </div>, + document.body, + ); +} diff --git a/src/components/shared/ToggleSwitch.module.scss b/src/components/shared/ToggleSwitch.module.scss new file mode 100644 index 0000000..4b1388c --- /dev/null +++ b/src/components/shared/ToggleSwitch.module.scss @@ -0,0 +1,39 @@ +.ToggleSwitch { + user-select: none; + border-radius: 4px; + border: 1px solid #525252; + color: var(--color-text); + background: var(--color-toggle-bg); + display: flex; + position: relative; + outline: none; + + &:focus { + border-color: var(--color-focus-blue); + } + + input { + position: absolute; + left: 0; + opacity: 0; + } + + label { + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + padding: 10px 0; + cursor: pointer; + } +} + +.slider { + z-index: 1; + position: absolute; + display: block; + left: 0; + height: 100%; + transition: left 0.2s ease-out; + background: var(--color-toggle-selected); +} diff --git a/src/components/shared/ToggleSwitch.tsx b/src/components/shared/ToggleSwitch.tsx new file mode 100644 index 0000000..56d7ffe --- /dev/null +++ b/src/components/shared/ToggleSwitch.tsx @@ -0,0 +1,67 @@ +import React, { useCallback, useMemo } from 'react'; + +import s0 from './ToggleSwitch.module.scss'; + +type Props = { + options: Array<{ label: string; value: string }>; + value: string; + name: string; + onChange: React.ChangeEventHandler<HTMLInputElement>; +}; + +function ToggleSwitch({ options, value, name, onChange }: Props) { + const idxSelected = useMemo(() => options.map((o) => o.value).indexOf(value), [options, value]); + + const getPortionPercentage = useCallback( + (idx: number) => { + const w = Math.floor(100 / options.length); + if (idx === options.length - 1) { + return 100 - options.length * w + w; + } else if (idx > -1) { + return w; + } + // value 对不上任何一项时 idxSelected 是 -1,滑块宽度按 0 算 + return 0; + }, + [options], + ); + + const sliderStyle = useMemo(() => { + return { + width: getPortionPercentage(idxSelected) + '%', + left: idxSelected * getPortionPercentage(0) + '%', + }; + }, [idxSelected, getPortionPercentage]); + + return ( + <div className={s0.ToggleSwitch}> + <div className={s0.slider} style={sliderStyle} /> + {options.map((o, idx) => { + const id = `${name}-${o.label}`; + const className = idx === 0 ? '' : 'border-left'; + return ( + <label + htmlFor={id} + key={id} + className={className} + style={{ + width: getPortionPercentage(idx) + '%', + }} + > + <input + id={id} + name={name} + type="radio" + value={o.value} + checked={value === o.value} + onChange={onChange} + /> + <div>{o.label}</div> + </label> + ); + })} + </div> + ); +} + +export default React.memo(ToggleSwitch); diff --git a/src/components/shared/Tooltip.tsx b/src/components/shared/Tooltip.tsx index 070dd52..2f00a54 100644 --- a/src/components/shared/Tooltip.tsx +++ b/src/components/shared/Tooltip.tsx @@ -16,11 +16,7 @@ export function Tooltip({ <RadixTooltip.Root> <RadixTooltip.Trigger asChild>{children}</RadixTooltip.Trigger> <RadixTooltip.Portal> - <RadixTooltip.Content - className="tooltip-content" - sideOffset={5} - aria-label={ariaLabel} - > + <RadixTooltip.Content className="tooltip-content" sideOffset={5} aria-label={ariaLabel}> {label} </RadixTooltip.Content> </RadixTooltip.Portal> diff --git a/src/components/shared/TrafficChartSample.tsx b/src/components/shared/TrafficChartSample.tsx index 516c20b..352af8a 100644 --- a/src/components/shared/TrafficChartSample.tsx +++ b/src/components/shared/TrafficChartSample.tsx @@ -23,7 +23,7 @@ const data1 = [23e3, 35e3, 46e3, 33e3, 90e3, 68e3, 23e3, 45e3]; const data2 = [184e3, 183e3, 196e3, 182e3, 190e3, 186e3, 182e3, 189e3]; const labels = data1.map((_, i) => i); -export default function TrafficChart({ id }) { +export default function TrafficChart({ id }: { id: number }) { chartJSResource.read(); const data = useMemo( @@ -42,7 +42,7 @@ export default function TrafficChart({ id }) { }, ], }), - [id] + [id], ); return ( |
