|
| 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 | +} |
0 commit comments