Skip to content

Commit 116d130

Browse files
committed
feat: add experimental app waffle component
1 parent f313d3f commit 116d130

File tree

7 files changed

+342
-0
lines changed

7 files changed

+342
-0
lines changed

packages/ui-react/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
"tailwind-merge": "^3.3.1",
4242
"tailwindcss": "^4.1.11"
4343
},
44+
"peerDependencies": {
45+
"react-dom": "^19.1.0"
46+
},
4447
"devDependencies": {
4548
"@storybook/addon-a11y": "^8.6.14",
4649
"@storybook/addon-docs": "8.6.14",
@@ -52,6 +55,7 @@
5255
"@storybook/test": "8.6.14",
5356
"@tailwindcss/cli": "^4.1.11",
5457
"@types/react": "^19.1.8",
58+
"@types/react-dom": "^19.1.6",
5559
"@vitest/browser": "^3.2.4",
5660
"@vitest/coverage-v8": "^3.2.4",
5761
"copyfiles": "^2.4.1",
@@ -60,6 +64,7 @@
6064
"storybook": "8.6.14",
6165
"typescript": "^5.8.3",
6266
"vite": "^6.3.5",
67+
"vite-plugin-mkcert": "^1.17.8",
6368
"vitest": "^3.2.4"
6469
}
6570
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { Meta, Preview } from "@storybook/react";
2+
import { AppWaffle } from "./app-waffle.js";
3+
4+
export default {
5+
title: "Experimental/App Waffle",
6+
component: AppWaffle,
7+
args: {
8+
referrer: "ui-react-storybook",
9+
usherUrl: "https://j26-usher.nihlen.io",
10+
},
11+
decorators: [
12+
(Story) => (
13+
<div className="flex justify-center">
14+
<Story />
15+
</div>
16+
),
17+
],
18+
} satisfies Meta;
19+
20+
export const Simple = {} satisfies Preview;
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { Popover } from "@base-ui-components/react/popover";
2+
import { LayoutGridIcon } from "lucide-react";
3+
import type { ComponentProps } from "react";
4+
import { usePreloadImages } from "../../lib/preload.js";
5+
import { Button } from "../button/button.js";
6+
import { useClients } from "./usher.js";
7+
8+
type RenderProp = ComponentProps<typeof Popover.Trigger>["render"];
9+
10+
type Props = {
11+
children?: RenderProp;
12+
referrer: string;
13+
usherUrl: string;
14+
};
15+
16+
const FALLBACK_LOGO_URL =
17+
"https://cdn.scouterna.net/jamboree26/images/app-placeholder.png";
18+
19+
function AppWaffle({ children, referrer, usherUrl }: Props) {
20+
const { clients, error } = useClients({ usherUrl });
21+
22+
const imageUrls = clients.map(
23+
(client) => client.logoUrl ?? FALLBACK_LOGO_URL,
24+
);
25+
usePreloadImages(imageUrls);
26+
27+
const clientsWithReferrer = clients.map((client) => {
28+
const url = new URL(client.url);
29+
url.searchParams.set("referrer", referrer);
30+
31+
return {
32+
...client,
33+
url: url.toString(),
34+
};
35+
});
36+
37+
children = children ?? (
38+
<Button size="medium-icon" variant="text" aria-label="Change app">
39+
<LayoutGridIcon />
40+
</Button>
41+
);
42+
43+
return (
44+
<Popover.Root>
45+
<Popover.Trigger render={children} />
46+
<Popover.Portal>
47+
<Popover.Positioner sideOffset={8}>
48+
<Popover.Popup className="origin-[var(--transform-origin)] rounded-lg bg-[canvas] p-4 text-gray-900 shadow outline outline-gray-200 transition-[transform,scale,opacity] data-[ending-style]:scale-90 data-[ending-style]:opacity-0 data-[starting-style]:scale-90 data-[starting-style]:opacity-0">
49+
<Popover.Arrow className="data-[side=bottom]:top-[-8px] data-[side=left]:right-[-13px] data-[side=left]:rotate-90 data-[side=right]:left-[-13px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-8px] data-[side=top]:rotate-180">
50+
<ArrowSvg />
51+
</Popover.Arrow>
52+
{error ? (
53+
<div>Something went wrong while loading your apps.</div>
54+
) : (
55+
<div className="grid grid-cols-3 gap-2 grid-flow-row-dense">
56+
{clientsWithReferrer.map((client) => (
57+
<a
58+
href={client.url}
59+
target="_blank"
60+
rel="noopener noreferrer"
61+
key={client.id}
62+
className="relative size-20"
63+
>
64+
<div
65+
className={`
66+
group/app-card
67+
absolute w-full h-full min-h-full rounded-lg pt-3 pb-1 px-1 flex flex-col items-center
68+
hover:h-auto hover:bg-gray-100/80
69+
select-none
70+
`}
71+
>
72+
<img
73+
src={client.logoUrl ?? FALLBACK_LOGO_URL}
74+
alt={client.name}
75+
className="size-9 aspect-square object-cover rounded-sm"
76+
/>
77+
<span
78+
className={`
79+
text-sm text-center mt-1 overflow-ellipsis line-clamp-1
80+
group-hover/app-card:line-clamp-none
81+
`}
82+
>
83+
{client.name}
84+
</span>
85+
</div>
86+
</a>
87+
))}
88+
</div>
89+
)}
90+
</Popover.Popup>
91+
</Popover.Positioner>
92+
</Popover.Portal>
93+
</Popover.Root>
94+
);
95+
}
96+
97+
function ArrowSvg(props: React.ComponentProps<"svg">) {
98+
return (
99+
// biome-ignore lint/a11y/noSvgWithoutTitle: This is only for display
100+
<svg
101+
width="20"
102+
height="10"
103+
viewBox="0 0 20 10"
104+
fill="none"
105+
{...props}
106+
aria-hidden
107+
>
108+
<path
109+
d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
110+
className="fill-[canvas]"
111+
/>
112+
<path
113+
d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
114+
className="fill-gray-200"
115+
/>
116+
</svg>
117+
);
118+
}
119+
120+
export { AppWaffle };
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useEffect, useMemo, useState } from "react";
2+
3+
export type UseClientsOptions = {
4+
usherUrl: string;
5+
};
6+
7+
export type Client = {
8+
id: string;
9+
url: string;
10+
name: string;
11+
alwaysShow: boolean;
12+
logoUrl: string | null;
13+
};
14+
15+
// Creates a temporary iframe, waits for a postMessage from it and sets a state variable to the received data
16+
export const useClients = ({ usherUrl }: UseClientsOptions) => {
17+
const [clients, setClients] = useState<Client[]>([]);
18+
const [error, setError] = useState<string | null>(null);
19+
const embedUrl = useMemo(() => new URL("/embed", usherUrl), [usherUrl]);
20+
21+
useEffect(() => {
22+
const iframe = document.createElement("iframe");
23+
iframe.src = embedUrl.toString();
24+
iframe.style.display = "none";
25+
document.body.appendChild(iframe);
26+
27+
const handleMessage = (event: MessageEvent) => {
28+
if (event.origin !== embedUrl.origin) return;
29+
if (!("type" in event.data)) return;
30+
if (!event.data.type.startsWith("usher-")) return;
31+
32+
iframe.remove();
33+
34+
if (event.data.type === "usher-clients") {
35+
setClients(event.data.data);
36+
} else if (event.data.type === "usher-error") {
37+
console.error("Received error from Usher iframe:", event.data.error);
38+
setError(event.data.error);
39+
} else {
40+
console.log("Received unknown message type:", event.data.type);
41+
}
42+
};
43+
window.addEventListener("message", handleMessage);
44+
return () => {
45+
window.removeEventListener("message", handleMessage);
46+
iframe.remove();
47+
};
48+
}, [embedUrl]);
49+
50+
return { clients, error };
51+
};

packages/ui-react/src/components/button/button.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ const buttonVariants = cva(
131131
size: {
132132
medium: "text-base px-4 h-10 rounded-lg",
133133
small: "text-sm px-3 h-8 rounded-md",
134+
"medium-icon": "text-base size-10 rounded-lg",
135+
"small-icon": "text-sm size-8 rounded-md",
134136
"tiny-icon": "text-sm p-1 size-6 rounded-md",
135137
},
136138
},
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useEffect } from "react";
2+
import type { PreloadOptions } from "react-dom";
3+
4+
export type PreloadInfo = PreloadOptions & {
5+
href: string;
6+
};
7+
8+
export function usePreloadImages(hrefs: string[]) {
9+
const isBrowser = typeof window !== "undefined";
10+
11+
useEffect(() => {
12+
if (!isBrowser) return;
13+
14+
const uniqueHrefs = Array.from(new Set(hrefs));
15+
16+
import("react-dom")
17+
.then(({ preload }) => {
18+
for (const href of uniqueHrefs) {
19+
preload(href, {
20+
as: "image",
21+
});
22+
}
23+
})
24+
.catch((err) => console.warn("Couldn't import react-dom:", err));
25+
}, [isBrowser, hrefs]);
26+
}

0 commit comments

Comments
 (0)