Skip to content

Commit 8921d86

Browse files
authored
[icons] Add static @blueprintjs/icons/next icon components (#8175)
1 parent 038d55e commit 8921d86

17 files changed

Lines changed: 808 additions & 24 deletions

.storybook/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const storybookConfig: StorybookConfig = {
3838
{ find: "@blueprintjs/core/lib", replacement: resolve(rootDir, "packages/core/lib") },
3939
{ find: "@blueprintjs/core", replacement: resolve(rootDir, "packages/core/src") },
4040
{ find: "@blueprintjs/datetime", replacement: resolve(rootDir, "packages/datetime") },
41+
{ find: "@blueprintjs/icons/next", replacement: resolve(rootDir, "packages/icons/next") },
4142
{ find: "@blueprintjs/icons", replacement: resolve(rootDir, "packages/icons") },
4243
{ find: "@blueprintjs/labs", replacement: resolve(rootDir, "packages/labs") },
4344
{ find: "@blueprintjs/select", replacement: resolve(rootDir, "packages/select") },
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/* !
2+
* (c) Copyright 2026 Palantir Technologies Inc. All rights reserved.
3+
*/
4+
5+
import { type Meta, type StoryObj } from "@storybook/react-vite";
6+
import { StoryLabel } from "@storybook-common";
7+
// eslint-disable-next-line import/no-extraneous-dependencies -- Storybook-only; mirrors icons package generator casing
8+
import { pascalCase } from "change-case";
9+
import React, { type ComponentType, type ReactElement, useCallback, useState } from "react";
10+
11+
import * as NextIcons from "@blueprintjs/icons/next";
12+
import { nextIconManifest, type NextIconManifestEntry } from "@blueprintjs/icons/next";
13+
import { Flex } from "@blueprintjs/labs";
14+
15+
import { Card } from "../card/card";
16+
17+
// -----------------------------------------------------------------------------
18+
// Constants & data
19+
20+
const GALLERY_MAX_WIDTH = 1024;
21+
const DISPLAY_ICON_SIZE = 32;
22+
23+
const ICON_GRID_CARD_STYLE: React.CSSProperties = {
24+
aspectRatio: "1 / 1",
25+
minWidth: 72,
26+
};
27+
28+
const OUTLINED_ICONS = [...nextIconManifest].sort((a, b) => a.name.localeCompare(b.name));
29+
const FILLED_ICONS = nextIconManifest.filter(e => e.hasFilled).sort((a, b) => a.name.localeCompare(b.name));
30+
31+
// -----------------------------------------------------------------------------
32+
// Storybook meta & stories
33+
34+
const meta = {
35+
title: "Icons/Next Icon Gallery",
36+
decorators: [galleryLayoutDecorator],
37+
argTypes: {
38+
size: { control: { type: "range", min: 12, max: 64, step: 4 } },
39+
},
40+
args: {
41+
size: DISPLAY_ICON_SIZE,
42+
},
43+
parameters: {
44+
actions: { disable: true },
45+
controls: { disableSaveFromUI: true },
46+
interactions: { disable: true },
47+
layout: "centered",
48+
},
49+
} satisfies Meta<{ size: number }>;
50+
51+
export default meta;
52+
53+
type Story = StoryObj<typeof meta>;
54+
55+
export const Outlined: Story = {
56+
render: ({ size }) => <NextIconGallery icons={OUTLINED_ICONS} variant="outlined" size={size} />,
57+
};
58+
59+
export const Filled: Story = {
60+
render: ({ size }) => <NextIconGallery icons={FILLED_ICONS} variant="filled" size={size} />,
61+
};
62+
63+
export const Compare: Story = {
64+
render: ({ size }) => <CompareGallery icons={FILLED_ICONS} size={size} />,
65+
};
66+
67+
// -----------------------------------------------------------------------------
68+
// Layout
69+
70+
function galleryLayoutDecorator(Story: ComponentType) {
71+
return (
72+
<div style={{ maxWidth: GALLERY_MAX_WIDTH }}>
73+
<Story />
74+
</div>
75+
);
76+
}
77+
78+
function NextIconGallery({
79+
icons,
80+
size,
81+
variant,
82+
}: {
83+
icons: NextIconManifestEntry[];
84+
size: number;
85+
variant: "outlined" | "filled";
86+
}) {
87+
return (
88+
<Flex flexDirection="column" gap={4}>
89+
<StoryLabel title={`${icons.length} ${variant} icons`} />
90+
<Flex flexWrap="wrap" gap={2}>
91+
{icons.map(entry => (
92+
<Card key={entry.name} title={entry.name} style={ICON_GRID_CARD_STYLE}>
93+
<Flex alignItems="center" justifyContent="center" style={{ height: "100%" }}>
94+
{renderNextIcon(entry.name, size, variant)}
95+
</Flex>
96+
</Card>
97+
))}
98+
</Flex>
99+
</Flex>
100+
);
101+
}
102+
103+
function CompareGallery({ icons, size }: { icons: NextIconManifestEntry[]; size: number }) {
104+
return (
105+
<Flex flexDirection="column" gap={4}>
106+
<StoryLabel
107+
title={`${icons.length} icons with both variants - click a card to swap between outlined and filled`}
108+
/>
109+
<Flex flexWrap="wrap" gap={2}>
110+
{icons.map(entry => (
111+
<CompareCard key={entry.name} iconName={entry.name} size={size} />
112+
))}
113+
</Flex>
114+
</Flex>
115+
);
116+
}
117+
118+
function CompareCard({ iconName, size }: { iconName: string; size: number }) {
119+
const [showFilled, setShowFilled] = useState(false);
120+
const handleClick = useCallback(() => setShowFilled(prev => !prev), []);
121+
122+
return (
123+
<Card
124+
title={`${iconName} (${showFilled ? "filled" : "outlined"})`}
125+
style={ICON_GRID_CARD_STYLE}
126+
interactive={true}
127+
onClick={handleClick}
128+
>
129+
<Flex alignItems="center" justifyContent="center" style={{ height: "100%" }}>
130+
{renderNextIcon(iconName, size, showFilled ? "filled" : "outlined")}
131+
</Flex>
132+
</Card>
133+
);
134+
}
135+
136+
// -----------------------------------------------------------------------------
137+
// Icon rendering
138+
139+
function renderNextIcon(iconName: string, pixelSize: number, variant: "outlined" | "filled"): ReactElement {
140+
const exportName = variant === "outlined" ? `${pascalCase(iconName)}Icon` : `${pascalCase(iconName)}FilledIcon`;
141+
const IconComponent = (NextIcons as unknown as Record<string, ComponentType<{ size?: number }>>)[exportName];
142+
if (IconComponent == null) {
143+
return <span title={`${exportName} not found`} />;
144+
}
145+
return <IconComponent size={pixelSize} />;
146+
}

packages/icons/next/index.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*
2+
* Copyright 2026 Palantir Technologies, Inc. All rights reserved.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
*/
5+
6+
// Type barrel for the `@blueprintjs/icons/next` subpath. It lives physically inside `next/` (rather
7+
// than only redirecting to `../lib/...` via package.json) so that TypeScript's auto-import generates
8+
// the canonical `@blueprintjs/icons/next` specifier instead of the deep `lib/esm/next/generated` path.
9+
export * from "../lib/esm/next/generated";

packages/icons/next/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"main": "../lib/cjs/next/generated/index.js",
3+
"module": "../lib/esm/next/generated/index.js",
4+
"typings": "./index.d.ts"
5+
}

packages/icons/package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@
99
"style": "lib/css/blueprint-icons.css",
1010
"files": [
1111
"lib",
12+
"next",
1213
"src",
13-
"icons.json"
14+
"icons.json",
15+
"icons-next.json",
16+
"icons-name-map.json"
1417
],
1518
"sideEffects": [
1619
"**/*.css"
1720
],
1821
"scripts": {
19-
"clean": "rm -rf coverage lib src/generated",
22+
"clean": "rm -rf coverage lib src/generated src/next/generated",
2023
"compile": "npm-run-all -s \"icons:verify\" \"generate-icon-src\" -p \"compile:*\" -p \"copy:*\"",
2124
"compile:esm": "tsc -p ./src/tsconfig.build.json",
2225
"compile:cjs": "tsc -p ./src/tsconfig.build.json -m commonjs --verbatimModuleSyntax false --outDir lib/cjs",
@@ -31,7 +34,7 @@
3134
"icons:add": "node scripts/add-icons.mjs",
3235
"icons:format": "node scripts/format-icons.mjs",
3336
"icons:verify": "node scripts/verify-icons.mjs",
34-
"generate-icon-src": "node scripts/generate-icon-fonts.mjs && node scripts/generate-icon-paths.mjs && node scripts/generate-icon-components.mjs",
37+
"generate-icon-src": "node scripts/generate-icon-fonts.mjs && node scripts/generate-icon-paths.mjs && node scripts/generate-icon-components.mjs && node scripts/generate-next-icon-components.mjs && node scripts/generate-icon-name-map.mjs",
3538
"lint": "run-p lint:scss lint:es",
3639
"lint:scss": "sass-lint",
3740
"lint:es": "es-lint",

packages/icons/scripts/common.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ export const NS = "bp6";
6666
export const ICON_SIZES = [16, 20];
6767
export const ICON_SIZES_PX = ICON_SIZES.map(n => `${n}px`);
6868

69+
/**
70+
* Resource subdirectories holding next-generation icon SVG sources.
71+
*/
72+
export const NEXT_ICON_DIRS = ["next/outlined", "next/filled"];
73+
6974
/**
7075
* We need to scale up the icon paths during conversion so that the icons do not get visually degraded
7176
* or compressed through rounding errors (svgicons2svgfont rasterizes the icons in order to convert them).

packages/icons/scripts/extractPathsFromResourceSvg.mjs

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,17 @@ import { iconResourcesDir } from "./common.mjs";
2323
import { optimizeSvg } from "./iconSvgoConfig.mjs";
2424

2525
/**
26-
* Extracts path `d` strings from an on-disk icon SVG. This matches the pipeline used for
27-
* {@link generate-icon-paths.mjs} and the path modules consumed by `<Icon />` from core.
26+
* Extracts path `d` strings from an SVG file on disk, running it through the same SVGO normalization
27+
* as the rest of the icon pipeline. This is the single place coupled to SVGO's output shape; callers
28+
* supply the file path. Used by both the legacy ({@link generate-icon-paths.mjs},
29+
* {@link generate-icon-components.mjs}) and next ({@link generate-next-icon-components.mjs}) pipelines.
2830
*
29-
* @param {16 | 20} iconSize
30-
* @param {string} iconName
31-
* @returns {Promise<string[]>}
31+
* @param {string} svgPath absolute path to an `.svg` file
32+
* @returns {string[]}
3233
*/
33-
export async function extractPathsFromResourceSvg(iconSize, iconName) {
34-
const path = join(iconResourcesDir, `${iconSize}px`, `${iconName}.svg`);
35-
const source = readFileSync(path, "utf-8");
36-
const optimized = optimizeSvg(source, path);
34+
export function extractPathsFromSvgFile(svgPath) {
35+
const source = readFileSync(svgPath, "utf-8");
36+
const optimized = optimizeSvg(source, svgPath);
3737
/** @type string[] */
3838
const paths = [];
3939
// Match `d` attributes on `<path>` elements from our normalized SVGO output.
@@ -44,3 +44,15 @@ export async function extractPathsFromResourceSvg(iconSize, iconName) {
4444
}
4545
return paths;
4646
}
47+
48+
/**
49+
* Extracts path `d` strings from a legacy 16px/20px resource icon SVG. This matches the pipeline used
50+
* for {@link generate-icon-paths.mjs} and the path modules consumed by `<Icon />` from core.
51+
*
52+
* @param {16 | 20} iconSize
53+
* @param {string} iconName
54+
* @returns {Promise<string[]>}
55+
*/
56+
export async function extractPathsFromResourceSvg(iconSize, iconName) {
57+
return extractPathsFromSvgFile(join(iconResourcesDir, `${iconSize}px`, `${iconName}.svg`));
58+
}

packages/icons/scripts/format-icons.mjs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { readdirSync, readFileSync, writeFileSync } from "node:fs";
1919
import { extname, join } from "node:path";
2020

2121
import { createCliLogger } from "./cliLogger.mjs";
22-
import { ICON_SIZES_PX, iconResourcesDir, repoRelative } from "./common.mjs";
22+
import { ICON_SIZES_PX, iconResourcesDir, NEXT_ICON_DIRS, repoRelative } from "./common.mjs";
2323
import { optimizeSvg } from "./iconSvgoConfig.mjs";
2424

2525
const logger = createCliLogger("icons:format");
@@ -29,16 +29,16 @@ async function main() {
2929
let changed = 0;
3030
let total = 0;
3131

32-
for (const size of ICON_SIZES_PX) {
33-
const sizeDir = join(iconResourcesDir, size);
34-
logger.info(repoRelative(sizeDir));
35-
const filenames = readdirSync(sizeDir)
32+
for (const subdir of [...ICON_SIZES_PX, ...NEXT_ICON_DIRS]) {
33+
const dir = join(iconResourcesDir, subdir);
34+
logger.info(repoRelative(dir));
35+
const filenames = readdirSync(dir)
3636
.filter(name => extname(name) === ".svg")
3737
.sort();
3838

3939
for (const filename of filenames) {
4040
total += 1;
41-
const path = join(sizeDir, filename);
41+
const path = join(dir, filename);
4242
const source = readFileSync(path, "utf8");
4343
const optimized = optimizeSvg(source, path);
4444
if (optimized !== source) {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright 2026 Palantir Technologies, Inc. All rights reserved.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
// @ts-check
17+
18+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
19+
import { join, resolve } from "node:path";
20+
21+
import { generatedSrcDir, getIconNamesInDirectory, iconResourcesDir, repoRelative } from "./common.mjs";
22+
import { validateIconNameMap } from "./iconNameMapValidation.mjs";
23+
24+
const iconNameMapPath = resolve(import.meta.dirname, "../icons-name-map.json");
25+
const generatedIconNameMapPath = join(generatedSrcDir, "iconNameMap.ts");
26+
27+
const legacyIconNames = getIconNamesInDirectory(join(iconResourcesDir, "16px"));
28+
const nextIconNames = getIconNamesInDirectory(join(iconResourcesDir, "next/outlined"));
29+
30+
const rawMap = readFileSync(iconNameMapPath, "utf8");
31+
/** @type {Record<string, string>} */
32+
const iconNameMap = JSON.parse(rawMap);
33+
34+
const errors = validateIconNameMap(rawMap, iconNameMap, legacyIconNames, nextIconNames);
35+
if (errors.length > 0) {
36+
throw new Error(`icons-name-map.json validation failed:\n${errors.map(e => ` - ${e}`).join("\n")}`);
37+
}
38+
39+
console.info(`Generating legacy→next icon name map (${Object.keys(iconNameMap).length} entries)...`);
40+
41+
const entries = Object.keys(iconNameMap)
42+
.sort()
43+
.map(name => ` ${JSON.stringify(name)}: ${JSON.stringify(iconNameMap[name])},`);
44+
45+
mkdirSync(generatedSrcDir, { recursive: true });
46+
writeFileSync(
47+
generatedIconNameMapPath,
48+
[
49+
"/*",
50+
" * Copyright 2026 Palantir Technologies, Inc. All rights reserved.",
51+
' * Licensed under the Apache License, Version 2.0 (the "License");',
52+
" */",
53+
"",
54+
'import type { IconName } from "../iconNames";',
55+
'import type { IconNextName } from "../next/generated/manifest";',
56+
"",
57+
"/**",
58+
' * Maps legacy ("current") Blueprint icon names to their next-generation',
59+
" * (`@blueprintjs/icons/next`) equivalents. Generated from `icons-name-map.json`.",
60+
" */",
61+
"export const LegacyToIconNextNameMap: Record<IconName, IconNextName> = {",
62+
...entries,
63+
"};",
64+
"",
65+
].join("\n"),
66+
);
67+
68+
console.info(`Read ${repoRelative(iconNameMapPath)}`);
69+
console.info(`Wrote ${repoRelative(generatedIconNameMapPath)}`);

0 commit comments

Comments
 (0)