diff --git a/packages/console/package.json b/packages/console/package.json index 32a1cb87f..df99c94b7 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -9,7 +9,7 @@ "preinstall": "npx only-allow pnpm", "precommit": "lint-staged", "start": "parcel src/index.html", - "dev": "PORT=5002 parcel src/index.html --public-url /console --no-hmr", + "dev": "PORT=5002 parcel src/index.html --public-url /console --no-hmr --no-cache", "check": "tsc --noEmit", "build": "pnpm check && rm -rf dist && parcel build src/index.html --no-autoinstall --public-url /console", "lint": "eslint --ext .ts --ext .tsx src", @@ -18,6 +18,7 @@ "dependencies": { "@logto/phrases": "^0.1.0", "@logto/schemas": "^0.1.0", + "@silverhand/essentials": "^1.1.6", "classnames": "^2.3.1", "i18next": "^21.6.12", "i18next-browser-languagedetector": "^6.1.3", @@ -51,7 +52,22 @@ "@/*": "./src/$1" }, "eslintConfig": { - "extends": "@silverhand/react" + "extends": "@silverhand/react", + "overrides": [ + { + "files": [ + "*.tsx" + ], + "rules": { + "unicorn/no-useless-undefined": [ + "error", + { + "checkArguments": false + } + ] + } + } + ] }, "stylelint": { "extends": "@silverhand/eslint-config-react/.stylelintrc" diff --git a/packages/console/src/components/CopyToClipboard/index.module.scss b/packages/console/src/components/CopyToClipboard/index.module.scss index 9b00e8df8..8dc478d86 100644 --- a/packages/console/src/components/CopyToClipboard/index.module.scss +++ b/packages/console/src/components/CopyToClipboard/index.module.scss @@ -25,3 +25,12 @@ } } } + +div.successTooltip { + background: #008a71; + color: #fff; + + &::after { + border-top-color: #008a71; + } +} diff --git a/packages/console/src/components/CopyToClipboard/index.tsx b/packages/console/src/components/CopyToClipboard/index.tsx index b6c902d14..982e6c3b9 100644 --- a/packages/console/src/components/CopyToClipboard/index.tsx +++ b/packages/console/src/components/CopyToClipboard/index.tsx @@ -1,21 +1,46 @@ -import React, { MouseEventHandler, SVGProps } from 'react'; +import classNames from 'classnames'; +import React, { forwardRef, MouseEventHandler, SVGProps, useEffect, useRef, useState } from 'react'; +import { TFuncKey, useTranslation } from 'react-i18next'; +import Tooltip from '../Tooltip'; import * as styles from './index.module.scss'; type Props = { value: string; }; -const CopyIcon = (props: SVGProps) => ( - - - +const CopyIcon = forwardRef>( + (props: SVGProps, reference) => ( + + + + ) ); +type CopyState = TFuncKey<'translation', 'admin_console.copy'>; + const CopyToClipboard = ({ value }: Props) => { - const copy: MouseEventHandler = () => { - // TO-DO: toast after finished - void navigator.clipboard.writeText(value); + const copyIconReference = useRef(null); + const [copyState, setCopyState] = useState('pending'); + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.copy' }); + + useEffect(() => { + copyIconReference.current?.addEventListener('mouseleave', () => { + setCopyState('pending'); + }); + }, []); + + const copy: MouseEventHandler = async () => { + setCopyState('copying'); + await navigator.clipboard.writeText(value); + setCopyState('copied'); }; return ( @@ -27,7 +52,12 @@ const CopyToClipboard = ({ value }: Props) => { >
{value} - + +
); diff --git a/packages/console/src/components/Tooltip/index.module.scss b/packages/console/src/components/Tooltip/index.module.scss new file mode 100644 index 000000000..a3df86dac --- /dev/null +++ b/packages/console/src/components/Tooltip/index.module.scss @@ -0,0 +1,25 @@ +@use '@/scss/underscore' as _; + +.container { + position: absolute; + transform: translate(-50%, -100%); + border-radius: 8px; + background: #191c1d; + color: #e0e3e3; + box-shadow: 0 2px 8px rgba(0, 0, 0, 8%); + padding: _.unit(2) _.unit(3); + font: var(--font-body); + + &::after { + content: ''; + display: block; + position: absolute; + border-width: 4px; + border-style: solid; + border-color: #191c1d transparent transparent; + left: 50%; + top: 100%; + transform: translateX(-50%); + box-shadow: 0 2px 8px rgba(0, 0, 0, 8%); + } +} diff --git a/packages/console/src/components/Tooltip/index.tsx b/packages/console/src/components/Tooltip/index.tsx new file mode 100644 index 000000000..d22d36bef --- /dev/null +++ b/packages/console/src/components/Tooltip/index.tsx @@ -0,0 +1,83 @@ +import { Nullable } from '@silverhand/essentials'; +import classNames from 'classnames'; +import React, { ReactNode, RefObject, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import * as styles from './index.module.scss'; + +type Props = { + content: ReactNode; + domRef: RefObject>; + className?: string; +}; + +type Position = { + top: number; + left: number; +}; + +const Tooltip = ({ content, domRef, className }: Props) => { + const [tooltipDom, setTooltipDom] = useState(); + const [position, setPosition] = useState(); + const isVisible = position !== undefined; + + useEffect(() => { + if (!domRef.current) { + return; + } + + const dom = domRef.current; + + const enterHandler = () => { + if (domRef.current) { + const { top, left, width } = domRef.current.getBoundingClientRect(); + const { scrollTop, scrollLeft } = document.documentElement; + setPosition({ top: top + scrollTop - 12, left: left + scrollLeft + width / 2 }); + } + }; + + const leaveHandler = () => { + setPosition(undefined); + }; + + dom.addEventListener('mouseenter', enterHandler); + dom.addEventListener('mouseleave', leaveHandler); + + return () => { + dom.removeEventListener('mouseenter', enterHandler); + dom.removeEventListener('mouseleave', leaveHandler); + }; + }, [domRef]); + + useEffect(() => { + if (!isVisible) { + if (tooltipDom) { + tooltipDom.remove(); + setTooltipDom(undefined); + } + + return; + } + + if (!tooltipDom) { + const dom = document.createElement('div'); + document.body.append(dom); + setTooltipDom(dom); + } + + return () => tooltipDom?.remove(); + }, [isVisible, tooltipDom]); + + if (!tooltipDom || !position) { + return null; + } + + return createPortal( +
+ {content} +
, + tooltipDom + ); +}; + +export default Tooltip; diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index 943f8ab51..bdd77ed1a 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -14,6 +14,11 @@ const translation = { }, admin_console: { title: 'Admin Console', + copy: { + pending: 'Copy', + copying: 'Copying', + copied: 'Copied', + }, tab_sections: { overview: 'Overview', resource_management: 'Resource Management', diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index 602411939..de41b6589 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -16,6 +16,11 @@ const translation = { }, admin_console: { title: '管理面板', + copy: { + pending: '拷贝', + copying: '拷贝中', + copied: '已拷贝', + }, tab_sections: { overview: '概览', resource_management: '资源管理', diff --git a/packages/ui/package.json b/packages/ui/package.json index fc215e7be..cd2b5c89f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -7,7 +7,7 @@ "preinstall": "npx only-allow pnpm", "precommit": "lint-staged", "start": "parcel src/index.html", - "dev": "PORT=5001 parcel src/index.html --no-hmr", + "dev": "PORT=5001 parcel src/index.html --no-hmr --no-cache", "check": "tsc --noEmit", "build": "pnpm check && rm -rf dist && parcel build src/index.html --no-autoinstall", "lint": "eslint --ext .ts --ext .tsx src", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17a9996cd..413cfd9f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,7 @@ importers: '@parcel/transformer-sass': ^2.3.1 '@silverhand/eslint-config': ^0.9.1 '@silverhand/eslint-config-react': ^0.9.3 + '@silverhand/essentials': ^1.1.6 '@silverhand/ts-config': ^0.9.1 '@silverhand/ts-config-react': ^0.9.3 '@types/lodash.kebabcase': ^4.1.6 @@ -51,6 +52,7 @@ importers: dependencies: '@logto/phrases': link:../phrases '@logto/schemas': link:../schemas + '@silverhand/essentials': 1.1.6 classnames: 2.3.1 i18next: 21.6.12 i18next-browser-languagedetector: 6.1.3 @@ -3127,6 +3129,14 @@ packages: lodash.pick: 4.4.0 dev: false + /@silverhand/essentials/1.1.6: + resolution: {integrity: sha512-Vl57z4HnEW/iSTHlFCPeJDLbg3OegrJy3gg0dbZsZwPifK0O5l5uBnrcpIYIPl3IhLSWw1ZqTyP7QWFpKadpEQ==} + engines: {node: '>=14.15.0', pnpm: '>=6'} + dependencies: + lodash.orderby: 4.6.0 + lodash.pick: 4.4.0 + dev: false + /@silverhand/ts-config-react/0.9.3_typescript@4.5.5: resolution: {integrity: sha512-oKI9/xxrqcLzCjoNxhDRKhk1HRfLKw1nvoWm4RQUHJWCSGBLHovuUB/qwwQXEPrJnwweRGfatFnbM5dTSKPwGA==} engines: {node: '>=14.15.0'}