Skip to content
Closed
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,7 @@ _No props beyond HTML attributes and `UIBaseProps`._

```ts
extensionUrl?: string
icon?: string | JSX.Element
onDismiss?: () => void
onInstall?: () => void
storageKey?: string
Expand Down
18 changes: 10 additions & 8 deletions src/components/immersive-landing/components/FirefoxPWABanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export const FirefoxPWABanner: Component<FirefoxPWABannerProps> = (props) => {
aria-label={texts().closeLabel}
>
<Icon
src="icon-[mdi--close]"
src="icon-[lucide--x]"
width={16}
height={16}
/>
Expand All @@ -148,13 +148,15 @@ export const FirefoxPWABanner: Component<FirefoxPWABannerProps> = (props) => {
{...{ class: CLASSES.firefoxBanner.media }}
>
<div {...{ class: CLASSES.firefoxBanner.iconWrap }}>
<Show when={browser() === "firefox"}>
<Icon
src="icon-[mdi--firefox]"
width={40}
height={40}
{...{ class: CLASSES.firefoxBanner.browserIcon }}
/>
<Show when={browser() === "firefox" && props.icon}>
{(icon) => (
<Icon
src={icon()}
width={40}
height={40}
{...{ class: CLASSES.firefoxBanner.browserIcon }}
/>
)}
</Show>
</div>

Expand Down
15 changes: 15 additions & 0 deletions src/components/immersive-landing/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,21 @@ export interface FirefoxPWABannerProps {
extensionUrl?: string;
storageKey?: string;
texts?: FirefoxPWABannerTexts;
/**
* The browser mark shown beside the text. Omit it and the banner renders
* without one.
*
* A default lived here as `icon-[mdi--firefox]`, and it was the only reason
* this library needed a second Iconify set. Everything else it draws is
* `lucide`, which has no brand glyphs, so one banner in one optional
* component obliged every consumer to install all of `-json/mdi` --
* and a consumer who installed only `lucide` got build warnings and a blank
* space, which is what happened on crates.vip.
*
* Accepts what `Icon` accepts: an Iconify class such as
* `"icon-[mdi--firefox]"`, or an inline SVG element.
*/
icon?: string | JSX.Element;
onInstall?: () => void;
onDismiss?: () => void;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const LanguageSwitcher: Layout<
when={!props.i18n.isLoading}
fallback={
<Icon
src="icon-[mdi--loading]"
src="icon-[lucide--loader-circle]"
{...{ class: CLASSES.loadingIcon }}
width={16}
height={16}
Expand Down
2 changes: 1 addition & 1 deletion src/components/table/MobileListView.layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const MobileListView: Layout<typeof tableMobileListViewRecipe, MobileListViewPro
<Empty>
<Empty.Icon>
<Icon
src={props.emptyIcon ?? "icon-[mdi--inbox-outline]"}
src={props.emptyIcon ?? "icon-[lucide--inbox]"}
width={24}
height={24}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ const ThemeColorPicker: Layout<typeof componentRecipe, ThemeColorPickerProps> =
>
{props.children ?? (
<Icon
src="icon-[mdi--palette]"
src="icon-[lucide--palette]"
width={16}
height={16}
{...{ class: store.themeColor() !== null ? CLASSES.iconActive : undefined }}
Expand Down
69 changes: 69 additions & 0 deletions tests/components/single-icon-set.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "bun:test";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";

/**
* Everything this library draws comes from one Iconify set.
*
* A consumer installs icon sets itself -- `@iconify-json/lucide` and so on --
* so every set the library reaches for is a set the consumer must know to
* install. Nothing states that requirement, and nothing fails loudly when it
* is unmet: the build prints `Cannot load icon set for "mdi"` among its
* warnings and the icon renders as empty space.
*
* That is what happened on crates.vip, which installed `lucide` because that
* is what the fleet uses. Five icons across `LanguageSwitcher`,
* `ThemeColorPicker`, `MobileListView` and the Firefox banner were `mdi`, and
* all five were blank. The library itself develops against `@iconify/json`,
* the whole collection, so it could never see this.
*
* The Firefox brand mark was the one icon with no lucide equivalent -- lucide
* has no brand glyphs. It is a prop now rather than a default, so a consumer
* who wants it supplies it and pays for the set, and everyone else pays
* nothing.
*/
const ALLOWED = "lucide";

const SRC = join(import.meta.dir, "../../src");

describe("icons come from one set", () => {
const files: string[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
if (statSync(path).isDirectory()) walk(path);
// Generated twins mirror their layout source, so a finding in one is the
// same finding in the other.
else if (/\.(ts|tsx|css)$/.test(entry) && !entry.includes(".generated."))
files.push(path);
}
};
walk(SRC);

// `icon-[set--name]` as it is written in a class or an `src`. Prose in a
// comment is not a reference, so the match has to be anchored to the
// delimiter a real one carries.
const references = new Map<string, string[]>();
for (const file of files) {
const text = readFileSync(file, "utf8");
for (const line of text.split("\n")) {
if (/^\s*(\*|\/\/)/.test(line)) continue;
for (const match of line.matchAll(/icon-\[([a-z0-9]+)--[a-z0-9-]+\]/g)) {
const set = match[1];
if (!references.has(set)) references.set(set, []);
references.get(set)?.push(file.replace(SRC, "src"));
}
}
}

it("finds icon references, so a broken walk cannot pass silently", () => {
expect(references.get(ALLOWED)?.length ?? 0).toBeGreaterThan(10);
});

it("uses no set other than the one consumers are told to install", () => {
const strays = [...references]
.filter(([set]) => set !== ALLOWED)
.map(([set, where]) => `${set}: ${[...new Set(where)].join(", ")}`);
expect(strays).toEqual([]);
});
});
Loading