Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
29 changes: 23 additions & 6 deletions .github/workflows/figma-icon-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
inputs:
output_dir:
type: string
description: "output directory"
description: 'output directory'
required: true
default: output
outputs:
Expand Down Expand Up @@ -169,12 +169,16 @@ jobs:

ROOT_TRAVERSE_COLOR_LOGO_IDS.forEach(
(id) =>
(colorLogoParentNode = colorLogoParentNode.children.find((a) => a.id === id)),
(colorLogoParentNode = colorLogoParentNode.children.find(
(a) => a.id === id,
)),
);

ROOT_TRAVERSE_COLOR_NORMAL_IDS.forEach(
(id) =>
(colorNormalParentNode = colorNormalParentNode.children.find((a) => a.id === id)),
(colorNormalParentNode = colorNormalParentNode.children.find(
(a) => a.id === id,
)),
);

ROOT_TRAVERSE_NAVIGATION_IDS.forEach(
Expand Down Expand Up @@ -207,7 +211,11 @@ jobs:
.join('') +
type;

idsToNameAndComponentSetId[child.id] = [name, component.id];
idsToNameAndComponentSetId[child.id] = {
name,
id: component.id,
description: response.componentSets[component.id]?.description,
};
});
}
}
Expand All @@ -226,7 +234,11 @@ jobs:
const processNodeId = async (nodeId) => {
const fileResponse = await fetch(images[nodeId], { method: 'GET' });
const svg = await fileResponse.text();
const [name, componentSetId] = idsToNameAndComponentSetId[nodeId];
const {
name,
id: componentSetId,
description,
} = idsToNameAndComponentSetId[nodeId];

const parsedName = name
.replace('Icon', '')
Expand All @@ -244,6 +256,7 @@ jobs:
content: svg,
id: componentSetId.replace(':', '-'),
parsedName: name.includes('Color') ? parsedName + 'Color' : parsedName,
description,
});
};

Expand Down Expand Up @@ -301,7 +314,11 @@ jobs:
);
});

const result = data.map(({ id, parsedName }) => ({ id, name: parsedName }));
const result = data.map(({ id, parsedName, description }) => ({
id,
name: parsedName,
description,
}));

fs.writeFileSync(
path.join(process.env.OUTPUT_DIR, 'result.json'),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import CustomRenderProvider from '@/features/docs/components/custom-render/provider';
import CustomRenderSummary from '@/features/docs/components/custom-render/summary';
import FoundationsIcons from '@/features/docs/components/foundations/icons';
import { createMetadata } from '@/helpers/metadata';

import type { Metadata } from 'next';

const TITLE = 'Icons';
const DESCRIPTION =
'원티드의 아이콘은 기능이나 콘텐츠를 시각적으로 표현하는 요소로, 사용자가 인터페이스를 빠르게 탐색할 수 있도록 돕습니다.';

export const metadata: Metadata = createMetadata({
title: TITLE,
description: DESCRIPTION,
image: '/foundations/Thumbnails.png',
});

const IconsPage = () => {
return (
<>
<CustomRenderSummary title={TITLE} description={DESCRIPTION} />

<CustomRenderProvider>
<FoundationsIcons />
</CustomRenderProvider>
</>
);
};

export default IconsPage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import {
Box,
ContentBadge,
FlexBox,
IconButton,
Popover,
PopoverContent,
PopoverTrigger,
Typography,
WithInteraction,
} from '@wanteddev/wds';
import * as Icons from '@wanteddev/wds-icon';
import { useCallback } from 'react';
import { camelCase } from 'change-case';

import { breakWordStyle } from '@/styles/text';

import { getKeywords } from '../helpers';

import {
iconDetailWrapperStyle,
iconGridStyle,
iconItemStyle,
iconPopoverWrapperStyle,
} from './style';

import type { MouseEvent } from 'react';

type IconItem = {
name: string;
description: string;
};

type Props = {
icons: Array<IconItem>;
};

const Collections = ({ icons }: Props) => {
const createSvg = useCallback((target: HTMLElement) => {
const svgElement = target
.closest('[data-role="icon-detail-popover"]')
?.querySelector('[data-role="icon-component-for-download"]');

if (!svgElement) return;

const cloned = svgElement.cloneNode(true) as SVGSVGElement;
cloned.removeAttribute('style');
cloned.removeAttribute('class');

if (!cloned.getAttribute('xmlns')) {
cloned.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
}

const svgString = new XMLSerializer().serializeToString(cloned);

return svgString.replaceAll('currentColor', '#171719');
}, []);

const handleDownloadSvg = useCallback(
(name: string) => (e: MouseEvent) => {
const svgString = createSvg(e.target as HTMLElement);

if (!svgString) return;

const blob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);

const a = document.createElement('a');
a.href = url;
a.download = `${camelCase(name.replace('Icon', ''))}.svg`;
a.click();

URL.revokeObjectURL(url);
},
[createSvg],
);

if (icons.length === 0) return null;

return (
<>
<Box sx={iconGridStyle}>
{icons.map((icon) => {
const IconComponent = Icons[icon.name as keyof typeof Icons];

return (
<Popover key={icon.name}>
<PopoverTrigger>
<WithInteraction variant="light">
<FlexBox
flexDirection="column"
alignItems="center"
gap="12px"
as="button"
type="button"
aria-label={`Show detail ${icon.name}`}
sx={iconItemStyle}
>
<IconComponent aria-hidden />
</FlexBox>
</WithInteraction>
</PopoverTrigger>

<PopoverContent
variant="custom"
sx={{
padding: '20px 24px 24px',
}}
data-role="icon-detail-popover"
wrapperProps={{ sx: iconPopoverWrapperStyle }}
>
<FlexBox flexDirection="column" gap="16px" flex="1">
<FlexBox gap="12px" justifyContent="space-between">
<Typography
variant="headline2"
weight="bold"
color="semantic.label.normal"
sx={breakWordStyle}
>
{camelCase(icon.name.replace('Icon', ''))}
</Typography>

<IconButton
aria-label={`Download ${icon.name} svg`}
size={20}
onClick={handleDownloadSvg(icon.name)}
>
<Icons.IconDownload aria-hidden />
</IconButton>
</FlexBox>

<FlexBox sx={iconDetailWrapperStyle}>
<IconComponent
data-role="icon-component-for-download"
aria-hidden
/>
</FlexBox>

{getKeywords(icon.description).length > 0 && (
<FlexBox gap="12px" flexDirection="column">
<Typography
color="semantic.label.neutral"
variant="label1"
weight="medium"
>
Keyword
</Typography>

<FlexBox flexWrap="wrap" gap="6px">
{getKeywords(icon.description).map((keyword, i) => (
<ContentBadge
color="neutral"
size="xsmall"
variant="solid"
key={i}
>
{keyword}
</ContentBadge>
))}
</FlexBox>
</FlexBox>
)}
</FlexBox>
</PopoverContent>
</Popover>
);
})}
</Box>
</>
);
};

export default Collections;
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { addOpacity, css, respondTo } from '@wanteddev/wds';

import type { Theme } from '@wanteddev/wds';

export const iconGridStyle = (theme: Theme) => css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
gap: 12px;

${respondTo(theme.breakpoint.sm)} {
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
}
`;

export const iconItemStyle = (theme: Theme) => css`
padding: 20px;
width: 100%;
border-radius: 12px;
aspect-ratio: 1/1;
border: none;
background-color: transparent;
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px ${theme.semantic.line.normal.alternative};
transition: box-shadow 0.15s ease;

&[aria-expanded='true'] {
box-shadow: inset 0 0 0 1px
${addOpacity(theme.semantic.primary.normal, theme.opacity[16])};

& > [wds-component='with-interaction'] {
background-color: ${theme.semantic.primary.normal};
opacity: 0.06;
}
}

${respondTo(theme.breakpoint.sm)} {
padding: 16px;
}
`;

export const iconDetailWrapperStyle = (theme: Theme) => css`
width: 100%;
height: 80px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 10px;
font-size: 24px;
box-shadow: inset 0 0 0 1px ${theme.semantic.line.normal.alternative};
`;

export const iconPopoverWrapperStyle = css`
min-width: unset !important;
max-width: 100%;
width: 380px;
padding-inline: var(--layout-padding-inline);

${respondTo('500px')} {
width: 100%;
}
`;
18 changes: 18 additions & 0 deletions docs/src/features/docs/components/foundations/icons/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const isColorIcon = (name: string) => {
return name.endsWith('Color');
};

export const isNavigationIcon = (name: string) => {
return name.startsWith('IconNavigation');
};

export const isSolidIcon = (name: string) => {
return !isColorIcon(name) && !isNavigationIcon(name);
};

export const getKeywords = (description: string) => {
const match = description.match(/키워드:\s*(.+)/);
if (!match) return [];

return match[1]?.split(',').map((keyword) => keyword.trim()) ?? [];
Comment on lines +13 to +17
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

descriptionnull 또는 undefined일 경우 런타임 오류가 발생할 수 있습니다.

icons.json에서 description이 누락된 경우 description.match()TypeError를 발생시킬 수 있습니다.

🛡️ 방어적 코드 제안
 export const getKeywords = (description: string) => {
+  if (!description) return [];
+
   const match = description.match(/키워드:\s*(.+)/);
   if (!match) return [];

   return match[1]?.split(',').map((keyword) => keyword.trim()) ?? [];
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const getKeywords = (description: string) => {
const match = description.match(/:\s*(.+)/);
if (!match) return [];
return match[1]?.split(',').map((keyword) => keyword.trim()) ?? [];
export const getKeywords = (description: string) => {
if (!description) return [];
const match = description.match(/:\s*(.+)/);
if (!match) return [];
return match[1]?.split(',').map((keyword) => keyword.trim()) ?? [];
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/src/features/docs/components/foundations/icons/helpers.ts` around lines
13 - 17, The getKeywords function can throw when description is null/undefined
because it calls description.match; update getKeywords to defensively handle
missing descriptions by checking description (or coercing to an empty string)
before calling match, e.g., early-return [] if description is falsy or use
(description ?? '') to run the regex safely; keep using the existing regex and
split logic and reference getKeywords and description.match in your changes.

};
Loading