@xex/ui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 iGattreX
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,13 @@
1
- # Temporary Holding Version
1
+ # @xex/ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ React components, app shell and `ThemeProvider` for the XeX design system.
4
+
5
+ ```tsx
6
+ import { Button, Tabs, DataTable, ThemeProvider } from '@xex/ui'
7
+ ```
8
+
9
+ - Peer dependencies: `react` and `react-dom` 19.
10
+ - Needs `@xex/tailwind-preset` in your Tailwind config and `@xex/tokens/styles.css` imported once. See the [repository README](../../README.md) for the full setup.
11
+ - Optional entry `@xex/ui/font-picker` adds a Google Fonts picker (it makes requests to Google Fonts and Fontsource).
12
+
13
+ MIT licensed.
@@ -0,0 +1,215 @@
1
+ // src/components/Spinner.tsx
2
+ import clsx from "clsx";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ var sizes = {
5
+ xs: "h-3 w-3",
6
+ sm: "h-4 w-4",
7
+ md: "h-5 w-5",
8
+ lg: "h-8 w-8",
9
+ xl: "h-12 w-12"
10
+ };
11
+ function Spinner({ size = "sm", label = "Loading", decorative = false, className }) {
12
+ return /* @__PURE__ */ jsxs(
13
+ "svg",
14
+ {
15
+ className: clsx(sizes[size], "animate-spin motion-reduce:animate-[spin_2s_linear_infinite]", className),
16
+ viewBox: "0 0 24 24",
17
+ fill: "none",
18
+ ...decorative ? { "aria-hidden": true } : { role: "status", "aria-label": label },
19
+ children: [
20
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4", className: "opacity-25" }),
21
+ /* @__PURE__ */ jsx("path", { d: "M4 12a8 8 0 018-8", stroke: "currentColor", strokeWidth: "4", strokeLinecap: "round", className: "opacity-90" })
22
+ ]
23
+ }
24
+ );
25
+ }
26
+
27
+ // src/components/Button.tsx
28
+ import clsx2 from "clsx";
29
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
30
+ var variants = {
31
+ primary: "bg-brand-600 hover:bg-brand-700 text-white shadow-sm",
32
+ secondary: "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 shadow-sm",
33
+ ghost: "bg-transparent text-gray-600 hover:bg-gray-100",
34
+ danger: "bg-red-600 hover:bg-red-700 text-white shadow-sm",
35
+ outline: "border border-brand-500 text-brand-700 hover:bg-brand-50 bg-transparent"
36
+ };
37
+ var sizes2 = {
38
+ xs: "px-2 py-1 text-xs rounded-md gap-1",
39
+ sm: "px-3 py-1.5 text-sm rounded-lg gap-1.5",
40
+ md: "px-4 py-2 text-sm rounded-lg gap-2",
41
+ lg: "px-5 py-2.5 text-base rounded-xl gap-2.5"
42
+ };
43
+ function Button({
44
+ variant = "primary",
45
+ size = "md",
46
+ loading = false,
47
+ icon,
48
+ iconRight,
49
+ disabled,
50
+ className,
51
+ children,
52
+ type = "button",
53
+ ...rest
54
+ }) {
55
+ return /* @__PURE__ */ jsxs2(
56
+ "button",
57
+ {
58
+ type,
59
+ disabled: disabled || loading,
60
+ "aria-busy": loading || void 0,
61
+ className: clsx2(
62
+ "inline-flex items-center justify-center font-medium transition-all duration-150",
63
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400 focus-visible:ring-offset-1",
64
+ "disabled:cursor-not-allowed disabled:opacity-50",
65
+ variants[variant],
66
+ sizes2[size],
67
+ className
68
+ ),
69
+ ...rest,
70
+ children: [
71
+ loading ? /* @__PURE__ */ jsx2(Spinner, { decorative: true }) : icon,
72
+ children,
73
+ iconRight
74
+ ]
75
+ }
76
+ );
77
+ }
78
+
79
+ // src/components/LoadingDots.tsx
80
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
81
+ function LoadingDots({ className }) {
82
+ return /* @__PURE__ */ jsxs3("span", { role: "status", "aria-label": "Loading", className: `xex-dots ${className ?? ""}`, children: [
83
+ /* @__PURE__ */ jsx3("i", {}),
84
+ /* @__PURE__ */ jsx3("i", {}),
85
+ /* @__PURE__ */ jsx3("i", {})
86
+ ] });
87
+ }
88
+
89
+ // src/theme/ThemeContext.tsx
90
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
91
+ import {
92
+ DEFAULT_FONT,
93
+ DEFAULT_THEME_ID,
94
+ SHADE_STEPS,
95
+ fontStack,
96
+ loadGoogleFont,
97
+ themes as defaultThemes
98
+ } from "@xex/tokens";
99
+ import { jsx as jsx4 } from "react/jsx-runtime";
100
+ var MAX_RECENT_FONTS = 5;
101
+ var ThemeContext = createContext(null);
102
+ function read(key) {
103
+ try {
104
+ return localStorage.getItem(key);
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+ function write(key, value) {
110
+ try {
111
+ localStorage.setItem(key, value);
112
+ } catch {
113
+ }
114
+ }
115
+ function readJson(key, guard) {
116
+ try {
117
+ const raw = read(key);
118
+ const parsed = raw ? JSON.parse(raw) : null;
119
+ return guard(parsed) ? parsed : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+ var isFont = (v) => typeof v === "object" && v !== null && typeof v.family === "string" && typeof v.category === "string";
125
+ function ThemeProvider({
126
+ children,
127
+ storagePrefix = "xex",
128
+ themes = defaultThemes,
129
+ defaultThemeId = DEFAULT_THEME_ID,
130
+ defaultFont = DEFAULT_FONT
131
+ }) {
132
+ const keys = useMemo(
133
+ () => ({
134
+ theme: `${storagePrefix}-theme`,
135
+ mode: `${storagePrefix}-mode`,
136
+ font: `${storagePrefix}-font`,
137
+ recent: `${storagePrefix}-recent-fonts`
138
+ }),
139
+ [storagePrefix]
140
+ );
141
+ const findTheme = useCallback((id) => themes.find((t) => t.id === id) ?? themes.find((t) => t.id === defaultThemeId) ?? themes[0], [themes, defaultThemeId]);
142
+ const [themeId, setThemeIdState] = useState(() => findTheme(read(keys.theme)).id);
143
+ const [mode, setModeState] = useState(() => {
144
+ const saved = read(keys.mode);
145
+ if (saved === "light" || saved === "dark") return saved;
146
+ return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
147
+ });
148
+ const [font, setFontState] = useState(() => readJson(keys.font, isFont) ?? defaultFont);
149
+ const [recentFonts, setRecentFonts] = useState(() => {
150
+ const saved = readJson(keys.recent, (v) => Array.isArray(v) && v.every(isFont));
151
+ return (saved ?? [defaultFont]).slice(0, MAX_RECENT_FONTS);
152
+ });
153
+ const theme = useMemo(() => findTheme(themeId), [findTheme, themeId]);
154
+ useEffect(() => {
155
+ loadGoogleFont(font);
156
+ document.documentElement.style.setProperty("--font-sans", fontStack(font));
157
+ }, [font]);
158
+ useEffect(() => recentFonts.forEach(loadGoogleFont), [recentFonts]);
159
+ const setFont = useCallback(
160
+ (next) => {
161
+ setFontState(next);
162
+ write(keys.font, JSON.stringify(next));
163
+ setRecentFonts((prev) => {
164
+ const list = [next, ...prev.filter((f) => f.family !== next.family)].slice(0, MAX_RECENT_FONTS);
165
+ write(keys.recent, JSON.stringify(list));
166
+ return list;
167
+ });
168
+ },
169
+ [keys]
170
+ );
171
+ useEffect(() => {
172
+ const root = document.documentElement;
173
+ for (const step of SHADE_STEPS) root.style.setProperty(`--brand-${step}`, theme.shades[step]);
174
+ }, [theme]);
175
+ useEffect(() => {
176
+ const root = document.documentElement;
177
+ root.dataset.mode = mode;
178
+ root.classList.toggle("dark", mode === "dark");
179
+ root.style.colorScheme = mode;
180
+ }, [mode]);
181
+ const setThemeId = useCallback(
182
+ (id) => {
183
+ const next = findTheme(id).id;
184
+ setThemeIdState(next);
185
+ write(keys.theme, next);
186
+ },
187
+ [findTheme, keys]
188
+ );
189
+ const setMode = useCallback(
190
+ (next) => {
191
+ setModeState(next);
192
+ write(keys.mode, next);
193
+ },
194
+ [keys]
195
+ );
196
+ const toggleMode = useCallback(() => setMode(mode === "dark" ? "light" : "dark"), [mode, setMode]);
197
+ const value = useMemo(
198
+ () => ({ theme, themes, setThemeId, mode, setMode, toggleMode, font, recentFonts, setFont }),
199
+ [theme, themes, setThemeId, mode, setMode, toggleMode, font, recentFonts, setFont]
200
+ );
201
+ return /* @__PURE__ */ jsx4(ThemeContext.Provider, { value, children });
202
+ }
203
+ function useTheme() {
204
+ const ctx = useContext(ThemeContext);
205
+ if (!ctx) throw new Error("useTheme must be used inside <ThemeProvider>");
206
+ return ctx;
207
+ }
208
+
209
+ export {
210
+ Spinner,
211
+ Button,
212
+ LoadingDots,
213
+ ThemeProvider,
214
+ useTheme
215
+ };
@@ -0,0 +1,16 @@
1
+ import * as react from 'react';
2
+ import { FontChoice } from '@xex/tokens';
3
+
4
+ /** Last-5-used chips + a searchable dropdown of every Google Font. */
5
+ declare function FontPicker(): react.JSX.Element;
6
+
7
+ declare function fetchFontCatalog(): Promise<FontChoice[]>;
8
+ interface CatalogState {
9
+ fonts: FontChoice[];
10
+ status: 'idle' | 'loading' | 'ready' | 'error';
11
+ retry: () => void;
12
+ }
13
+ /** Loads the catalog when `enabled` first becomes true. */
14
+ declare function useFontCatalog(enabled: boolean): CatalogState;
15
+
16
+ export { type CatalogState, FontPicker, fetchFontCatalog, useFontCatalog };
@@ -0,0 +1,325 @@
1
+ import {
2
+ Button,
3
+ LoadingDots,
4
+ useTheme
5
+ } from "./chunk-3M3PAPT3.js";
6
+
7
+ // src/font-picker/FontPicker.tsx
8
+ import { useEffect as useEffect2, useId, useLayoutEffect, useMemo, useRef, useState as useState2 } from "react";
9
+ import clsx from "clsx";
10
+ import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid";
11
+ import { ArrowUturnLeftIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
12
+ import { DEFAULT_FONT, FONT_CATEGORIES as FONT_CATEGORIES2, SYSTEM_FONT, fontStack, loadFontPreviews } from "@xex/tokens";
13
+
14
+ // src/font-picker/catalog.ts
15
+ import { useEffect, useState } from "react";
16
+ import { FONT_CATEGORIES } from "@xex/tokens";
17
+ var KNOWN_CATEGORIES = new Set(FONT_CATEGORIES.map((c) => c.value));
18
+ var catalogCache = null;
19
+ var catalogPromise = null;
20
+ function fetchFontCatalog() {
21
+ if (catalogCache) return Promise.resolve(catalogCache);
22
+ catalogPromise ??= fetch("https://api.fontsource.org/v1/fonts?type=google").then((r) => {
23
+ if (!r.ok) throw new Error(`Font catalog request failed (${r.status})`);
24
+ return r.json();
25
+ }).then((list) => {
26
+ catalogCache = list.filter((e) => KNOWN_CATEGORIES.has(e.category)).map((e) => ({ family: e.family, category: e.category, weights: e.weights })).sort((a, b) => a.family.localeCompare(b.family));
27
+ return catalogCache;
28
+ }).catch((err) => {
29
+ catalogPromise = null;
30
+ throw err;
31
+ });
32
+ return catalogPromise;
33
+ }
34
+ function useFontCatalog(enabled) {
35
+ const [fonts, setFonts] = useState(catalogCache ?? []);
36
+ const [status, setStatus] = useState(catalogCache ? "ready" : "idle");
37
+ const [attempt, setAttempt] = useState(0);
38
+ useEffect(() => {
39
+ if (!enabled || status === "ready") return;
40
+ let live = true;
41
+ setStatus("loading");
42
+ fetchFontCatalog().then((list) => {
43
+ if (!live) return;
44
+ setFonts(list);
45
+ setStatus("ready");
46
+ }).catch(() => live && setStatus("error"));
47
+ return () => {
48
+ live = false;
49
+ };
50
+ }, [enabled, attempt]);
51
+ return { fonts, status, retry: () => setAttempt((n) => n + 1) };
52
+ }
53
+
54
+ // src/font-picker/FontPicker.tsx
55
+ import { jsx, jsxs } from "react/jsx-runtime";
56
+ var PAGE_SIZE = 40;
57
+ function FontPicker() {
58
+ const { font, recentFonts, setFont } = useTheme();
59
+ const [open, setOpen] = useState2(false);
60
+ const [query, setQuery] = useState2("");
61
+ const [category, setCategory] = useState2("");
62
+ const [visible, setVisible] = useState2(PAGE_SIZE);
63
+ const uid = useId();
64
+ const listId = `${uid}-list`;
65
+ const optId = (i) => `${uid}-opt-${i}`;
66
+ const [active, setActive] = useState2(0);
67
+ const rootRef = useRef(null);
68
+ const triggerRef = useRef(null);
69
+ const listRef = useRef(null);
70
+ const pendingScroll = useRef(false);
71
+ const scrollToActive = useRef(false);
72
+ const [viewIdx, setViewIdx] = useState2(0);
73
+ const catalog = useFontCatalog(open);
74
+ useEffect2(() => {
75
+ if (!open) return;
76
+ const onDown = (e) => {
77
+ if (!rootRef.current?.contains(e.target)) setOpen(false);
78
+ };
79
+ const onKey = (e) => {
80
+ if (e.key !== "Escape") return;
81
+ setOpen(false);
82
+ triggerRef.current?.focus();
83
+ };
84
+ document.addEventListener("mousedown", onDown);
85
+ document.addEventListener("keydown", onKey);
86
+ return () => {
87
+ document.removeEventListener("mousedown", onDown);
88
+ document.removeEventListener("keydown", onKey);
89
+ };
90
+ }, [open]);
91
+ const matches = useMemo(() => {
92
+ const q = query.trim().toLowerCase();
93
+ const all = [SYSTEM_FONT, ...catalog.fonts];
94
+ return all.filter((f) => (!category || f.category === category) && (!q || f.family.toLowerCase().includes(q)));
95
+ }, [catalog.fonts, query, category]);
96
+ const syncView = (list) => {
97
+ const rowH = list.querySelector("li")?.getBoundingClientRect().height || 32;
98
+ setViewIdx(Math.floor(list.scrollTop / rowH / 10) * 10);
99
+ };
100
+ useEffect2(() => {
101
+ if (!open) {
102
+ pendingScroll.current = false;
103
+ return;
104
+ }
105
+ const idx = !query && !category ? matches.findIndex((f) => f.family === font.family) : -1;
106
+ setActive(Math.max(idx, 0));
107
+ if (idx >= 0) {
108
+ setVisible(Math.max(PAGE_SIZE, (Math.floor(idx / PAGE_SIZE) + 2) * PAGE_SIZE));
109
+ pendingScroll.current = true;
110
+ } else {
111
+ setVisible(PAGE_SIZE);
112
+ setViewIdx(0);
113
+ listRef.current?.scrollTo({ top: 0 });
114
+ }
115
+ }, [open, matches]);
116
+ useLayoutEffect(() => {
117
+ if (!pendingScroll.current) return;
118
+ const list = listRef.current;
119
+ const el = list?.querySelector('[aria-selected="true"]');
120
+ if (!list || !el) return;
121
+ list.scrollTop = el.offsetTop - list.clientHeight / 2 + el.clientHeight / 2;
122
+ syncView(list);
123
+ pendingScroll.current = false;
124
+ }, [visible, matches]);
125
+ useLayoutEffect(() => {
126
+ if (!scrollToActive.current) return;
127
+ const list = listRef.current;
128
+ const el = open ? document.getElementById(optId(active)) : null;
129
+ if (!list || !el) return;
130
+ scrollToActive.current = false;
131
+ if (el.offsetTop < list.scrollTop) list.scrollTop = el.offsetTop;
132
+ else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight;
133
+ }, [active, open, visible]);
134
+ const onScroll = (e) => {
135
+ const el = e.currentTarget;
136
+ syncView(el);
137
+ if (el.scrollTop + el.clientHeight >= el.scrollHeight - 80) setVisible((v) => Math.min(v + PAGE_SIZE, matches.length));
138
+ };
139
+ useEffect2(() => {
140
+ if (open) loadFontPreviews(matches.slice(Math.max(0, viewIdx - 10), Math.min(visible, viewIdx + 50)));
141
+ }, [open, matches, visible, viewIdx]);
142
+ const choose = (f) => {
143
+ setFont(f);
144
+ setOpen(false);
145
+ triggerRef.current?.focus();
146
+ };
147
+ const openList = () => {
148
+ setQuery("");
149
+ setCategory("");
150
+ setOpen(true);
151
+ };
152
+ const moveActive = (next) => {
153
+ const i = Math.min(Math.max(next, 0), matches.length - 1);
154
+ scrollToActive.current = true;
155
+ setActive(i);
156
+ setVisible((v) => Math.max(v, Math.min(i + 20, matches.length)));
157
+ };
158
+ const onSearchKeyDown = (e) => {
159
+ if (e.key === "ArrowDown") moveActive(active + 1);
160
+ else if (e.key === "ArrowUp") moveActive(active - 1);
161
+ else if (e.key === "PageDown") moveActive(active + 8);
162
+ else if (e.key === "PageUp") moveActive(active - 8);
163
+ else if (e.key === "Enter" && matches[active]) choose(matches[active]);
164
+ else return;
165
+ e.preventDefault();
166
+ };
167
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
168
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2", children: [
169
+ /* @__PURE__ */ jsxs(
170
+ "div",
171
+ {
172
+ ref: rootRef,
173
+ className: "relative w-full max-w-sm",
174
+ onMouseDown: (e) => {
175
+ if (open && !(e.target instanceof HTMLInputElement) && listRef.current?.parentElement?.contains(e.target)) e.preventDefault();
176
+ },
177
+ onBlur: (e) => {
178
+ if (open && e.relatedTarget && !rootRef.current?.contains(e.relatedTarget)) setOpen(false);
179
+ },
180
+ children: [
181
+ /* @__PURE__ */ jsxs(
182
+ "button",
183
+ {
184
+ ref: triggerRef,
185
+ type: "button",
186
+ "aria-haspopup": "listbox",
187
+ "aria-expanded": open,
188
+ "aria-controls": open ? listId : void 0,
189
+ onClick: () => open ? setOpen(false) : openList(),
190
+ onKeyDown: (e) => {
191
+ if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
192
+ e.preventDefault();
193
+ openList();
194
+ }
195
+ },
196
+ className: "flex w-full cursor-default items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:border-brand-400",
197
+ children: [
198
+ /* @__PURE__ */ jsx("span", { className: "truncate", style: { fontFamily: fontStack(font) }, children: font.family }),
199
+ /* @__PURE__ */ jsx(ChevronUpDownIcon, { className: "h-5 w-5 shrink-0 text-gray-400" })
200
+ ]
201
+ }
202
+ ),
203
+ open && /* @__PURE__ */ jsxs("div", { className: "absolute left-0 right-0 z-40 mt-1 rounded-xl border border-gray-200 bg-white p-2 shadow-xl", children: [
204
+ /* @__PURE__ */ jsxs("div", { className: "relative", children: [
205
+ /* @__PURE__ */ jsx(MagnifyingGlassIcon, { className: "pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" }),
206
+ /* @__PURE__ */ jsx(
207
+ "input",
208
+ {
209
+ autoFocus: true,
210
+ value: query,
211
+ onChange: (e) => setQuery(e.target.value),
212
+ onKeyDown: onSearchKeyDown,
213
+ role: "combobox",
214
+ "aria-expanded": true,
215
+ "aria-controls": listId,
216
+ "aria-autocomplete": "list",
217
+ "aria-label": "Search fonts",
218
+ "aria-activedescendant": matches[active] ? optId(active) : void 0,
219
+ placeholder: "Search Google Fonts\u2026",
220
+ className: "w-full rounded-lg border border-gray-300 bg-white py-1.5 pl-8 pr-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:border-brand-400"
221
+ }
222
+ )
223
+ ] }),
224
+ /* @__PURE__ */ jsx("div", { className: "mt-2 flex flex-wrap gap-1", children: [{ value: "", label: "All" }, ...FONT_CATEGORIES2].map((c) => /* @__PURE__ */ jsx(
225
+ "button",
226
+ {
227
+ type: "button",
228
+ onClick: () => setCategory(c.value),
229
+ className: clsx(
230
+ "rounded-full px-2.5 py-0.5 text-xs font-medium",
231
+ category === c.value ? "bg-brand-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
232
+ ),
233
+ children: c.label
234
+ },
235
+ c.value
236
+ )) }),
237
+ /* @__PURE__ */ jsxs("ul", { id: listId, ref: listRef, role: "listbox", "aria-label": "Fonts", onScroll, className: "relative mt-2 max-h-64 overflow-y-auto", children: [
238
+ catalog.status === "loading" && /* @__PURE__ */ jsxs("li", { className: "px-3 py-2 text-sm text-gray-500", children: [
239
+ "Loading fonts ",
240
+ /* @__PURE__ */ jsx(LoadingDots, {})
241
+ ] }),
242
+ catalog.status === "error" && /* @__PURE__ */ jsxs("li", { className: "px-3 py-2 text-sm text-red-600", children: [
243
+ "Couldn\u2019t load the font list.",
244
+ " ",
245
+ /* @__PURE__ */ jsx("button", { type: "button", className: "font-medium underline", onClick: catalog.retry, children: "Retry" })
246
+ ] }),
247
+ matches.slice(0, visible).map((f, i) => /* @__PURE__ */ jsx("li", { id: optId(i), role: "option", "aria-selected": f.family === font.family, onMouseMove: () => i !== active && setActive(i), children: /* @__PURE__ */ jsxs(
248
+ "button",
249
+ {
250
+ type: "button",
251
+ title: f.family,
252
+ tabIndex: -1,
253
+ onClick: () => choose(f),
254
+ className: clsx(
255
+ "flex w-full cursor-default items-center justify-between rounded-md px-3 py-1.5 text-left text-sm text-gray-800",
256
+ i === active && "bg-gray-100 ring-2 ring-inset ring-brand-300",
257
+ f.family === font.family && "bg-brand-50 font-medium"
258
+ ),
259
+ children: [
260
+ /* @__PURE__ */ jsx("span", { style: { fontFamily: fontStack(f) }, children: f.family }),
261
+ /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2 text-xs text-gray-400", children: [
262
+ f.category,
263
+ f.family === font.family && /* @__PURE__ */ jsx(CheckIcon, { className: "h-4 w-4 text-brand-600" })
264
+ ] })
265
+ ]
266
+ }
267
+ ) }, f.family)),
268
+ catalog.status === "ready" && matches.length === 0 && /* @__PURE__ */ jsx("li", { className: "px-3 py-2 text-sm text-gray-500", children: "No fonts match." }),
269
+ visible < matches.length && /* @__PURE__ */ jsxs("li", { className: "px-3 py-2 text-xs text-gray-400", children: [
270
+ "Showing ",
271
+ visible,
272
+ " of ",
273
+ matches.length,
274
+ " \u2014 scroll for more"
275
+ ] })
276
+ ] })
277
+ ] })
278
+ ]
279
+ }
280
+ ),
281
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", style: { fontFamily: fontStack(DEFAULT_FONT) }, children: [
282
+ /* @__PURE__ */ jsxs("p", { className: "text-sm text-gray-500", children: [
283
+ "Selected: ",
284
+ /* @__PURE__ */ jsx("span", { className: "font-semibold text-gray-900", children: font.family }),
285
+ font.stack ? "" : /* @__PURE__ */ jsxs("span", { className: "text-gray-400", children: [
286
+ " \xB7 ",
287
+ font.category
288
+ ] })
289
+ ] }),
290
+ /* @__PURE__ */ jsx(
291
+ Button,
292
+ {
293
+ size: "xs",
294
+ variant: "ghost",
295
+ disabled: font.family === DEFAULT_FONT.family,
296
+ onClick: () => setFont(DEFAULT_FONT),
297
+ icon: /* @__PURE__ */ jsx(ArrowUturnLeftIcon, { className: "h-4 w-4" }),
298
+ title: `Reset to ${DEFAULT_FONT.family}`,
299
+ children: "Reset"
300
+ }
301
+ )
302
+ ] })
303
+ ] }),
304
+ /* @__PURE__ */ jsxs("div", { children: [
305
+ /* @__PURE__ */ jsx("p", { className: "mb-1.5 text-xs text-gray-500", children: "Recently used" }),
306
+ /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: recentFonts.map((f) => /* @__PURE__ */ jsx(
307
+ Button,
308
+ {
309
+ size: "sm",
310
+ variant: f.family === font.family ? "primary" : "secondary",
311
+ title: f.family,
312
+ onClick: () => setFont(f),
313
+ style: { fontFamily: fontStack(f) },
314
+ children: f.family
315
+ },
316
+ f.family
317
+ )) })
318
+ ] })
319
+ ] });
320
+ }
321
+ export {
322
+ FontPicker,
323
+ fetchFontCatalog,
324
+ useFontCatalog
325
+ };