Aligned with latest utils and types packages post submodule dismantle
This commit is contained in:
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
# Swap the comments on the following lines if you don't wish to use zero-installs
|
||||
# Documentation here: https://yarnpkg.com/features/zero-installs
|
||||
!.yarn/cache
|
||||
#.pnp.*
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
# Production
|
||||
build
|
||||
|
||||
# Miscellaneous
|
||||
*.local
|
||||
.DS_Store
|
||||
|
||||
helper
|
||||
6
Jenkinsfile
vendored
Normal file
6
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
@Library('jenkins-shared') _
|
||||
kanikoPipeline(
|
||||
repoName: 'Icon',
|
||||
branch: env.BRANCH_NAME ?: 'main',
|
||||
isNpmLib: true
|
||||
)
|
||||
37
README.md
Normal file
37
README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
# Armco Icon Component
|
||||
|
||||
## Overview
|
||||
This package provides a flexible, theme-aware React Icon component supporting SVG, base64, image URLs, and identifier-based icon loading. It includes dynamic styling, event handling, and registry-based caching for efficient icon management.
|
||||
|
||||
## Source Files
|
||||
|
||||
- **Icon.tsx**: Main React component. Handles icon loading, parsing, registry caching, dynamic styling, and event handlers. Supports SVG, base64, raw image, and identifier/URL sources. Uses `withTheme` HOC for theme context.
|
||||
- **enums.ts**: Enumerations for icon source types, valid image MIME types, tile types, and popover slots.
|
||||
- **helper.ts**: Utility functions for:
|
||||
- Inferring icon type from source
|
||||
- Parsing SVG strings and base64
|
||||
- Creating React elements from SVG DOM
|
||||
- Applying dynamic styles (fill, stroke, size, classes)
|
||||
- Color helpers for theme/toggle/hover states
|
||||
- **types.ts**: TypeScript interfaces for icon props, state, registry, attributes, events, and API responses.
|
||||
- **Icon.component.scss**: SCSS styles for icon presentation and hover effects.
|
||||
- **vite-env.d.ts**: Vite client types.
|
||||
|
||||
## Key Features
|
||||
- **Dynamic Source Handling**: Accepts icon as string, object, or IconSource; supports SVG, base64, image URLs, and identifier-based API fetch.
|
||||
- **Registry Caching**: Prevents duplicate network requests for icons; caches both promises and loaded icons.
|
||||
- **Theme & State Styling**: Applies fill/stroke/colors based on theme, hover, and toggle state; supports per-path coloring.
|
||||
- **Event Handling**: Supports onClick, onMouseEnter, onMouseLeave via props.
|
||||
- **SVG to React Conversion**: Converts SVG DOM to ReactNode for full React event support.
|
||||
|
||||
## Usage
|
||||
```tsx
|
||||
import Icon from "@armco/icon"
|
||||
<Icon icon="md.MdEdit" attributes={{ colors: { fillColor: "#333" } }} />
|
||||
```
|
||||
|
||||
## Development
|
||||
- Written in TypeScript, React class component.
|
||||
- Utilities and types are modularized for clarity and reuse.
|
||||
- SCSS for styling.
|
||||
36
build-tools/build.sh
Executable file
36
build-tools/build.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get the directory of the current script
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Default values
|
||||
DEV_FLAG=""
|
||||
|
||||
# Parse arguments
|
||||
for arg in "$@"
|
||||
do
|
||||
case $arg in
|
||||
--dev)
|
||||
DEV_FLAG="--dev"
|
||||
shift # Remove --dev from processing
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "[BUILD:SH] Dev flag is: $DEV_FLAG"
|
||||
echo "[BUILD:SH] Removing build if exists"
|
||||
rm -rf build
|
||||
echo "[BUILD:SH] Checking TS Types"
|
||||
npx tsc
|
||||
echo "[BUILD:SH] Initiating build..."
|
||||
# Conditionally use vite-dev.config.ts if --dev flag is present
|
||||
if [ "$DEV_FLAG" == "--dev" ]; then
|
||||
vite build --config vite-dev.config.ts
|
||||
else
|
||||
vite build
|
||||
fi
|
||||
|
||||
echo "[BUILD:SH] Running post processor scripts..."
|
||||
# Run Post processors: Update style imports in .js files, create component modules
|
||||
node "$SCRIPT_DIR/post-processor.js" build/cjs $DEV_FLAG
|
||||
node "$SCRIPT_DIR/post-processor.js" build/es $DEV_FLAG
|
||||
33
build-tools/generate-module.js
Normal file
33
build-tools/generate-module.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { promises as fs } from "fs"
|
||||
import { dirname, resolve } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const exclusions = ["Icon.js", "helper2.js"]
|
||||
|
||||
async function generateModule(fileName, isDev) {
|
||||
if (!exclusions.includes(fileName) && fileName.endsWith(".js")) {
|
||||
const dir = fileName.slice(0, -3)
|
||||
const name = `@armco/icon/${dir}`
|
||||
const packageJsonContent = {
|
||||
name,
|
||||
main: `../${isDev ? "build/" : ""}cjs/${dir}.js`,
|
||||
module: `../${isDev ? "build/" : ""}es/${dir}.js`,
|
||||
types: `../${isDev ? "build/" : ""}types/${dir}.d.ts`,
|
||||
}
|
||||
const dirPath = resolve(__dirname, `../${isDev ? "" : "build/"}${dir}`)
|
||||
try {
|
||||
await fs.mkdir(dirPath, { recursive: true })
|
||||
await fs.writeFile(
|
||||
resolve(dirPath, "package.json"),
|
||||
JSON.stringify(packageJsonContent, null, 2),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`Error processing directory ${dirPath}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default generateModule
|
||||
24
build-tools/post-processor.js
Normal file
24
build-tools/post-processor.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { readdir } from "fs/promises"
|
||||
import generateModule from "./generate-module.js"
|
||||
|
||||
async function postProcessor(dir, isDev) {
|
||||
try {
|
||||
const files = await readdir(dir)
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
await generateModule(file, isDev)
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`Error processing directory ${dir}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const targetDir = process.argv[2]
|
||||
|
||||
if (targetDir) {
|
||||
postProcessor(targetDir, process.argv.includes("--dev"))
|
||||
} else {
|
||||
console.error("Please provide the build directory to run post processor on.")
|
||||
process.exit(1)
|
||||
}
|
||||
4637
package-lock.json
generated
Normal file
4637
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
67
package.json
Normal file
67
package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@armco/icon",
|
||||
"version": "0.0.10",
|
||||
"type": "module",
|
||||
"main": "build/cjs/Icon.js",
|
||||
"module": "build/es/Icon.js",
|
||||
"types": "build/types/Icon.d.ts",
|
||||
"scripts": {
|
||||
"build": "./build-tools/build.sh",
|
||||
"build:sm": "./build-tools/build.sh --dev",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint .",
|
||||
"publish:sh": "./publish.sh",
|
||||
"publish:local": "./publish-local.sh"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
],
|
||||
"plugins": [
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"react/jsx-no-target-blank": "off"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"prettier": "prettier-config-nick",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://gitea.armco.dev/ReStruct-Corporate-Advantage/icon.git"
|
||||
},
|
||||
"keywords": [
|
||||
"components",
|
||||
"atomic",
|
||||
"building-blocks",
|
||||
"foundation"
|
||||
],
|
||||
"files": [
|
||||
"build"
|
||||
],
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://gitea.armco.dev/ReStruct-Corporate-Advantage/icon/issues"
|
||||
},
|
||||
"homepage": "https://gitea.armco.dev/ReStruct-Corporate-Advantage/icon#readme",
|
||||
"devDependencies": {
|
||||
"@armco/types": "^0.0.22",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"react": "^18.3.1",
|
||||
"sass-embedded": "^1.93.3",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-plugin-externalize-deps": "^0.10.0",
|
||||
"vitest": "^4.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@armco/utils": "^0.0.31"
|
||||
}
|
||||
}
|
||||
16
publish-local.sh
Executable file
16
publish-local.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
semver=${1:-patch}
|
||||
|
||||
set -e
|
||||
|
||||
npm run build
|
||||
cp package.json build/
|
||||
sed -i '' -E 's/"build"/"*"/' build/package.json
|
||||
|
||||
sed -i '' 's#"build/cjs/Icon.js"#"cjs/Icon.js"#' build/package.json
|
||||
sed -i '' 's#"build/es/Icon.js"#"es/Icon.js"#' build/package.json
|
||||
sed -i '' 's#"build/types/Icon.d.ts"#"types/Icon.d.ts"#' build/package.json
|
||||
|
||||
cd build
|
||||
npm pack --pack-destination ~/__Projects__/Common
|
||||
16
publish.sh
Executable file
16
publish.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
semver=${1:-patch}
|
||||
|
||||
set -e
|
||||
npm --no-git-tag-version version ${semver}
|
||||
npm run build
|
||||
cp package.json build/
|
||||
sed -i '' -E 's/"build"/"*"/' build/package.json
|
||||
|
||||
sed -i '' 's#"build/cjs/Icon.js"#"cjs/Icon.js"#' build/package.json
|
||||
sed -i '' 's#"build/es/Icon.js"#"es/Icon.js"#' build/package.json
|
||||
sed -i '' 's#"build/types/Icon.d.ts"#"types/Icon.d.ts"#' build/package.json
|
||||
|
||||
cd build
|
||||
npm publish --access public --loglevel verbose
|
||||
7
src/Icon.component.scss
Executable file
7
src/Icon.component.scss
Executable file
@@ -0,0 +1,7 @@
|
||||
.ar-Icon {
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover.hover-shadow {
|
||||
box-shadow: 0px 4px 8px var(--ar-shadow);
|
||||
}
|
||||
}
|
||||
199
src/Icon.tsx
Normal file
199
src/Icon.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { cloneElement, Component, ReactElement } from "react"
|
||||
import {
|
||||
ArIconSourceTypes,
|
||||
ArValidImageMimeTypes,
|
||||
} from "./enums"
|
||||
import {
|
||||
IconRegistry,
|
||||
IconSource,
|
||||
IconProps,
|
||||
IconState
|
||||
} from "./types"
|
||||
import { get, getStatic, retry } from "@armco/utils/network"
|
||||
import { withTheme } from "@armco/utils/HOC"
|
||||
|
||||
import {
|
||||
applyStyles,
|
||||
createElementFromSvg,
|
||||
inferIconType,
|
||||
parseSvgB64String,
|
||||
parseSvgString,
|
||||
placeholder,
|
||||
} from "./helper"
|
||||
import "./Icon.component.scss"
|
||||
|
||||
const iconRegistry: IconRegistry = {}
|
||||
|
||||
class Icon extends Component<IconProps, IconState> {
|
||||
constructor(props: IconProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
hovered: false,
|
||||
toggled: props.toggled || false,
|
||||
milk: placeholder,
|
||||
type: ArIconSourceTypes.svgString,
|
||||
}
|
||||
this.parseIconDescriptor = this.parseIconDescriptor.bind(this)
|
||||
this.handleIconResponse = this.handleIconResponse.bind(this)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.icon && this.parseIconDescriptor(this.props.icon)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: IconProps, prevState: IconState) {
|
||||
if (this.props.icon && this.props.icon !== prevProps.icon) {
|
||||
// Fetch icon if icon changed in props
|
||||
this.parseIconDescriptor(this.props.icon)
|
||||
} else if (
|
||||
JSON.stringify(prevProps.attributes) !==
|
||||
JSON.stringify(this.props.attributes) ||
|
||||
this.state.hovered !== prevState.hovered ||
|
||||
this.state.toggled !== prevState.toggled ||
|
||||
this.state.milk !== prevState.milk
|
||||
) {
|
||||
// Apply styles, if hovered, toggled or attributes changed
|
||||
this.state.milk &&
|
||||
this.setState({
|
||||
shake: applyStyles.call(
|
||||
this,
|
||||
this.state.milk,
|
||||
this.props,
|
||||
this.state,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
handleIconResponse(res: any, source: string, type: ArIconSourceTypes) {
|
||||
const contentType = res.headers.get("content-type")
|
||||
let milk
|
||||
if (
|
||||
type === ArIconSourceTypes.identifier ||
|
||||
contentType?.includes(ArValidImageMimeTypes.SVG)
|
||||
) {
|
||||
milk = parseSvgString(res.body)
|
||||
} else if (
|
||||
contentType &&
|
||||
Object.values(ArValidImageMimeTypes).findIndex((imageMimeType) =>
|
||||
contentType.includes(imageMimeType),
|
||||
) > -1
|
||||
) {
|
||||
milk = (
|
||||
<img
|
||||
src={source as any}
|
||||
alt="ar-icon"
|
||||
className={`ar-Icon ${this.props.attributes?.classes}${this.props.hoverShadow ? " hover-shadow p-1" : ""
|
||||
}`}
|
||||
style={this.props.attributes?.styles}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
console.error("Unsupported content type.")
|
||||
milk = placeholder as SVGSVGElement
|
||||
}
|
||||
milk && this.setState({ milk, type })
|
||||
iconRegistry[source].icon = milk
|
||||
}
|
||||
|
||||
parseIconDescriptor = (icon: IconSource | string | object): void => {
|
||||
const isIconSourceType = typeof icon === "object" && "source" in icon
|
||||
const iconTypeExists = isIconSourceType && !!ArIconSourceTypes[icon.type]
|
||||
let source = isIconSourceType ? icon.source : icon
|
||||
const type = iconTypeExists ? icon.type : inferIconType(source)
|
||||
let milk
|
||||
|
||||
if (
|
||||
type === ArIconSourceTypes.identifier ||
|
||||
type === ArIconSourceTypes.URL
|
||||
) {
|
||||
source = source as string
|
||||
const url =
|
||||
type === ArIconSourceTypes.URL
|
||||
? source
|
||||
: "/icon/" + source.replace(/\./g, "/")
|
||||
const fetcher = () =>
|
||||
(type === ArIconSourceTypes.URL ? get : getStatic)(url)
|
||||
|
||||
// Checking for existing promise is done to prevent multiple concurrent API calls for the same icon.
|
||||
// Check if API has already been called (iconRegistry[source].promise is placed in registry as soon as call is made)
|
||||
if (iconRegistry[source]) {
|
||||
if (iconRegistry[source].icon) {
|
||||
// If promise exists in registry as well as corresponding icon too, just set the icon to state
|
||||
this.setState({
|
||||
milk: iconRegistry[source].icon ?? placeholder,
|
||||
type,
|
||||
})
|
||||
} else {
|
||||
// If just promise exists resolve it
|
||||
iconRegistry[source].promise?.then((res) =>
|
||||
this.handleIconResponse(res, source as string, type),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// If neither of promise and icon exist, make a fresh API call
|
||||
const promise = retry(fetcher).then((res) => {
|
||||
this.handleIconResponse(res, source as string, type)
|
||||
return res
|
||||
})
|
||||
iconRegistry[source] = { promise }
|
||||
}
|
||||
} else {
|
||||
switch (type) {
|
||||
case ArIconSourceTypes.b64:
|
||||
milk = parseSvgB64String(source as string)
|
||||
break
|
||||
case ArIconSourceTypes.raw:
|
||||
milk = (
|
||||
<img
|
||||
src={source as any}
|
||||
alt="ar-icon"
|
||||
className={`ar-Icon ${this.props.attributes?.classes}${this.props.hoverShadow ? " hover-shadow p-1" : ""
|
||||
}`}
|
||||
style={this.props.attributes?.styles}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case ArIconSourceTypes.svgString:
|
||||
milk = parseSvgString(source as string)
|
||||
break
|
||||
default:
|
||||
console.error("Unsupported icon type.")
|
||||
milk = placeholder as SVGSVGElement
|
||||
}
|
||||
milk && this.setState({ milk, type: type as ArIconSourceTypes })
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseEnter = () => {
|
||||
this.setState({ hovered: true })
|
||||
this.props.events?.onMouseEnter?.()
|
||||
}
|
||||
|
||||
handleMouseLeave = () => {
|
||||
this.setState({ hovered: false })
|
||||
this.props.events?.onMouseLeave?.()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { shake, type } = this.state
|
||||
|
||||
if (!shake) {
|
||||
return null
|
||||
} else if (type === ArIconSourceTypes.raw) {
|
||||
return shake as ReactElement
|
||||
} else {
|
||||
return cloneElement(
|
||||
createElementFromSvg(shake as SVGSVGElement) as ReactElement,
|
||||
{
|
||||
onMouseEnter: this.handleMouseEnter,
|
||||
onMouseLeave: this.handleMouseLeave,
|
||||
onClick: this.props.events?.onClick,
|
||||
onClickCapture: () => this.setState({ toggled: !this.state.toggled }),
|
||||
} as React.HTMLAttributes<SVGElement>,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default withTheme<IconProps>(Icon)
|
||||
29
src/enums.ts
Normal file
29
src/enums.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export enum ArIconSourceTypes {
|
||||
URL = "URL",
|
||||
identifier = "identifier",
|
||||
svgString = "svgString",
|
||||
b64 = "b64",
|
||||
raw = "raw",
|
||||
}
|
||||
|
||||
export enum ArIconTileTypes {
|
||||
COMFY = "comfy",
|
||||
COMPACT = "compact",
|
||||
LIST = "list",
|
||||
}
|
||||
|
||||
export enum ArValidImageMimeTypes {
|
||||
JPEG = "image/jpeg",
|
||||
PNG = "image/png",
|
||||
GIF = "image/gif",
|
||||
WEBP = "image/webp",
|
||||
SVG = "image/svg+xml",
|
||||
TIFF = "image/tiff",
|
||||
BMP = "image/bmp",
|
||||
ICON = "image/x-icon",
|
||||
}
|
||||
|
||||
export enum ArPopoverSlots {
|
||||
POPOVER = "popover",
|
||||
ANCHOR = "anchor",
|
||||
}
|
||||
218
src/helper.ts
Normal file
218
src/helper.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
createElement as createReactElement,
|
||||
CSSProperties,
|
||||
ReactElement,
|
||||
} from "react"
|
||||
import { ArThemes } from "@armco/utils"
|
||||
import {
|
||||
ArIconSourceTypes,
|
||||
ArValidImageMimeTypes,
|
||||
} from "./enums"
|
||||
import {
|
||||
IconProps,
|
||||
IconState
|
||||
} from "./types"
|
||||
|
||||
const parser = new DOMParser()
|
||||
export const placeholder = parser
|
||||
.parseFromString(
|
||||
`<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="20" height="20" x="2" y="2" fill="none" stroke="#000" stroke-width="2"rx="2"></rect>
|
||||
</svg>`,
|
||||
ArValidImageMimeTypes.SVG,
|
||||
)
|
||||
.querySelector("svg") as SVGSVGElement
|
||||
const iconIdRegex = /^[a-zA-Z0-9]{2,3}[.\/][a-zA-Z0-9]+$/
|
||||
|
||||
export const inferIconType = (source: string | object): ArIconSourceTypes => {
|
||||
// At this point, icon is not IconSource, it's string or raw
|
||||
if (typeof source === "object") {
|
||||
return ArIconSourceTypes.raw
|
||||
}
|
||||
if (source.startsWith("data:image/svg+xml;base64,")) {
|
||||
return ArIconSourceTypes.b64
|
||||
} else if (source.startsWith("<svg")) {
|
||||
return ArIconSourceTypes.svgString
|
||||
} else if (source.startsWith("http://") || source.startsWith("https://")) {
|
||||
return ArIconSourceTypes.URL
|
||||
} else if (iconIdRegex.test(source)) {
|
||||
return ArIconSourceTypes.identifier
|
||||
} else {
|
||||
return ArIconSourceTypes.identifier
|
||||
}
|
||||
}
|
||||
|
||||
export const parseSvgString = (svgString: string): SVGSVGElement => {
|
||||
try {
|
||||
const doc = parser.parseFromString(svgString, ArValidImageMimeTypes.SVG)
|
||||
const svg = doc.querySelector("svg")
|
||||
return svg === null ? placeholder : svg
|
||||
} catch (error) {
|
||||
console.warn("Error parsing SVG string:", error)
|
||||
return placeholder
|
||||
}
|
||||
}
|
||||
|
||||
export const parseSvgB64String = (svgB64String: string): SVGSVGElement => {
|
||||
try {
|
||||
const svgString = atob(svgB64String)
|
||||
return parseSvgString(svgString)
|
||||
} catch (error) {
|
||||
console.error("Error decoding base64 SVG string:", error)
|
||||
return placeholder
|
||||
}
|
||||
}
|
||||
|
||||
export const createElementFromSvg = (juice: SVGSVGElement): React.ReactNode => {
|
||||
const createElement = (node: Element): React.ReactNode => {
|
||||
const children = Array.from(node.children).map(createElement)
|
||||
const props = Array.from(node.attributes).reduce((acc, attr) => {
|
||||
if (attr.name === "style") {
|
||||
// Parse the style attribute string into an object as React expects style to be object and not
|
||||
// string on components
|
||||
const styleObject = attr.value
|
||||
.split(";")
|
||||
.reduce((styleAcc, styleAttr) => {
|
||||
const [key, value] = styleAttr.split(":").map((str) => str.trim())
|
||||
if (key && value) {
|
||||
styleAcc[key] = value
|
||||
}
|
||||
return styleAcc
|
||||
}, {} as Record<string, string>)
|
||||
acc["style"] = styleObject
|
||||
} else {
|
||||
acc[attr.name] = attr.value
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
return createReactElement(node.tagName, props, ...children)
|
||||
}
|
||||
return createElement(juice)
|
||||
}
|
||||
|
||||
export const getStrokeColor = (
|
||||
props: IconProps,
|
||||
state?: IconState,
|
||||
): string | undefined => {
|
||||
const { attributes, theme } = props
|
||||
const { colors } = attributes || {}
|
||||
const {
|
||||
strokeColor,
|
||||
toggleStrokeColor,
|
||||
hoverStrokeColor,
|
||||
darkStrokeColor,
|
||||
darkToggleStrokeColor,
|
||||
darkHoverStrokeColor,
|
||||
} = colors || {}
|
||||
const { hovered, toggled } = state || {}
|
||||
|
||||
if (theme === ArThemes.DARK1) {
|
||||
if (hovered && darkHoverStrokeColor)
|
||||
return darkHoverStrokeColor || hoverStrokeColor
|
||||
if (toggled && darkToggleStrokeColor)
|
||||
return darkToggleStrokeColor || toggleStrokeColor
|
||||
return darkStrokeColor || strokeColor
|
||||
} else {
|
||||
if (hovered && hoverStrokeColor) return hoverStrokeColor
|
||||
if (toggled && toggleStrokeColor) return toggleStrokeColor
|
||||
return strokeColor
|
||||
}
|
||||
}
|
||||
|
||||
export const getFillColor = (
|
||||
props: IconProps,
|
||||
state?: IconState,
|
||||
): string | undefined => {
|
||||
const { attributes, theme } = props
|
||||
const { colors } = attributes || {}
|
||||
const {
|
||||
fillColor,
|
||||
toggleFillColor,
|
||||
hoverFillColor,
|
||||
darkFillColor,
|
||||
darkToggleFillColor,
|
||||
darkHoverFillColor,
|
||||
} = colors || {}
|
||||
const { hovered, toggled } = state || {}
|
||||
|
||||
if (theme === ArThemes.DARK1) {
|
||||
if (hovered && darkHoverFillColor)
|
||||
return darkHoverFillColor || hoverFillColor
|
||||
if (toggled && darkToggleFillColor)
|
||||
return darkToggleFillColor || toggleFillColor
|
||||
return darkFillColor || fillColor
|
||||
} else {
|
||||
if (hovered && hoverFillColor) return hoverFillColor
|
||||
if (toggled && toggleFillColor) return toggleFillColor
|
||||
return fillColor
|
||||
}
|
||||
}
|
||||
export const applyStyles = (
|
||||
milk: SVGSVGElement | ReactElement,
|
||||
props: IconProps,
|
||||
state?: IconState,
|
||||
) => {
|
||||
if (!(milk instanceof SVGSVGElement)) {
|
||||
return
|
||||
}
|
||||
const { attributes, fillPath, hoverShadow } = props
|
||||
const { strokeWidth, styles } = attributes || {}
|
||||
const classes =
|
||||
(attributes?.classes || "") + (hoverShadow ? " hover-shadow p-1" : "")
|
||||
|
||||
const fillColor = getFillColor(props, state)
|
||||
const strokeColor = getStrokeColor(props, state)
|
||||
|
||||
const height =
|
||||
attributes?.height ||
|
||||
(typeof styles?.height === "number"
|
||||
? styles?.height + "px"
|
||||
: styles?.height) ||
|
||||
attributes?.size ||
|
||||
"1rem"
|
||||
const width =
|
||||
attributes?.width ||
|
||||
(typeof styles?.width === "number"
|
||||
? styles?.width + "px"
|
||||
: styles?.width) ||
|
||||
attributes?.size ||
|
||||
"1rem"
|
||||
const shake = milk.cloneNode(true) as SVGSVGElement
|
||||
|
||||
fillColor && shake.setAttribute("fill", fillColor)
|
||||
strokeColor && shake.setAttribute("stroke", strokeColor)
|
||||
strokeWidth && shake.setAttribute("stroke-width", strokeWidth)
|
||||
shake.setAttribute("width", width)
|
||||
shake.setAttribute("height", height)
|
||||
|
||||
shake.setAttribute("class", `ar-Icon${classes ? " " + classes : ""}`)
|
||||
styles &&
|
||||
Object.keys(styles).forEach((key) => {
|
||||
shake.style[key as any] = styles[key as keyof CSSProperties] as string
|
||||
})
|
||||
|
||||
if (fillPath) {
|
||||
let paths: Array<SVGPathElement> | null = Array.from(
|
||||
shake.querySelectorAll("path"),
|
||||
)
|
||||
paths =
|
||||
fillPath === true
|
||||
? paths
|
||||
: fillPath.length !== undefined
|
||||
? paths.filter((_, index) => fillPath.includes(index))
|
||||
: null
|
||||
paths?.forEach((path) => {
|
||||
// Override stroke and color of "path" node inside SVG at below line.
|
||||
strokeColor &&
|
||||
!!path.getAttribute("stroke") &&
|
||||
path.setAttribute("stroke", strokeColor)
|
||||
strokeWidth &&
|
||||
!!path.getAttribute("stroke-width") &&
|
||||
path.setAttribute("stroke-width", strokeWidth)
|
||||
fillColor &&
|
||||
!!path.getAttribute("fill") &&
|
||||
path.setAttribute("fill", fillColor)
|
||||
})
|
||||
}
|
||||
return shake
|
||||
}
|
||||
82
src/types.ts
Normal file
82
src/types.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { CSSProperties, ReactElement } from "react"
|
||||
import { PageItem } from "@armco/types"
|
||||
import { ArThemes } from "@armco/utils"
|
||||
import { ArIconSourceTypes, ArPopoverSlots } from "./enums"
|
||||
|
||||
export interface IconSource {
|
||||
source: string | object
|
||||
type: ArIconSourceTypes
|
||||
}
|
||||
|
||||
export interface IconAttributes {
|
||||
height?: string
|
||||
width?: string
|
||||
size?: string
|
||||
strokeWidth?: string
|
||||
colors?: {
|
||||
fillColor?: string
|
||||
strokeColor?: string
|
||||
strokeWidth?: string
|
||||
toggleFillColor?: string
|
||||
toggleStrokeColor?: string
|
||||
hoverFillColor?: string
|
||||
hoverStrokeColor?: string
|
||||
darkFillColor?: string
|
||||
darkStrokeColor?: string
|
||||
darkToggleFillColor?: string
|
||||
darkToggleStrokeColor?: string
|
||||
darkHoverFillColor?: string
|
||||
darkHoverStrokeColor?: string
|
||||
}
|
||||
styles?: CSSProperties
|
||||
classes?: string
|
||||
}
|
||||
|
||||
export interface IconEvents {
|
||||
onClick?: (e?: MouseEvent) => void
|
||||
onMouseEnter?: (e?: MouseEvent) => void
|
||||
onMouseLeave?: (e?: MouseEvent) => void
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
icon?: IconSource | string | object
|
||||
slot?: ArPopoverSlots
|
||||
attributes?: IconAttributes
|
||||
events?: IconEvents
|
||||
fillPath?: Array<number> | boolean
|
||||
toggled?: boolean
|
||||
hoverShadow?: boolean
|
||||
theme?: ArThemes
|
||||
}
|
||||
|
||||
export interface IconState {
|
||||
hovered: boolean
|
||||
toggled: boolean
|
||||
milk: SVGSVGElement | ReactElement
|
||||
shake?: SVGSVGElement | ReactElement
|
||||
type: ArIconSourceTypes
|
||||
}
|
||||
|
||||
export type IconRegistry = {
|
||||
[key: string]: {
|
||||
promise?: Promise<any>
|
||||
icon?: SVGSVGElement | ReactElement
|
||||
}
|
||||
}
|
||||
|
||||
export interface IconStyles {
|
||||
fillColor?: string
|
||||
strokeColor?: string
|
||||
bgColor?: string
|
||||
strokeWidth?: string
|
||||
}
|
||||
|
||||
export interface IconResponse extends PageItem {
|
||||
name: string
|
||||
group: string
|
||||
svg: string
|
||||
icon: string
|
||||
tags?: Array<string>
|
||||
description?: string
|
||||
message?: string
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"outDir": "build",
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
45
vite-dev.config.ts
Normal file
45
vite-dev.config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { resolve } from "path"
|
||||
import { defineConfig } from "vitest/config"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import dts from "vite-plugin-dts"
|
||||
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"
|
||||
import { externalizeDeps } from "vite-plugin-externalize-deps"
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
dts({ outDir: "build/types" }),
|
||||
cssInjectedByJsPlugin({
|
||||
jsAssetsFilterFunction: (chunk) => chunk.fileName.includes("Icon"),
|
||||
}),
|
||||
externalizeDeps(),
|
||||
],
|
||||
build: {
|
||||
outDir: "build",
|
||||
lib: {
|
||||
entry: [
|
||||
resolve(__dirname, "src/helper.ts"),
|
||||
resolve(__dirname, "src/Icon.tsx"),
|
||||
],
|
||||
},
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: [
|
||||
{
|
||||
format: "es",
|
||||
dir: "build/es",
|
||||
entryFileNames: "[name].js",
|
||||
chunkFileNames: "[name].js",
|
||||
},
|
||||
{
|
||||
format: "cjs",
|
||||
dir: "build/cjs",
|
||||
entryFileNames: "[name].js",
|
||||
chunkFileNames: "[name].js",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
44
vite.config.ts
Normal file
44
vite.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { resolve } from "path"
|
||||
import { defineConfig } from "vitest/config"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import dts from "vite-plugin-dts"
|
||||
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"
|
||||
import { externalizeDeps } from "vite-plugin-externalize-deps"
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
dts({ outDir: "build/types" }),
|
||||
cssInjectedByJsPlugin({
|
||||
jsAssetsFilterFunction: (chunk) => chunk.fileName.includes("Icon"),
|
||||
}),
|
||||
externalizeDeps(),
|
||||
],
|
||||
build: {
|
||||
outDir: "build",
|
||||
lib: {
|
||||
entry: [
|
||||
resolve(__dirname, "src/helper.ts"),
|
||||
resolve(__dirname, "src/Icon.tsx"),
|
||||
],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: [
|
||||
{
|
||||
format: "es",
|
||||
dir: "build/es",
|
||||
entryFileNames: "[name].js",
|
||||
chunkFileNames: "[name].js",
|
||||
},
|
||||
{
|
||||
format: "cjs",
|
||||
dir: "build/cjs",
|
||||
entryFileNames: "[name].js",
|
||||
chunkFileNames: "[name].js",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user