@pure-ds/core 0.6.7 → 0.6.9

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.
@@ -137,6 +137,7 @@ const SURFACE_CONTEXT_PATHS = new Set([
137
137
  const DARK_MODE_PATH_MARKER = ".darkMode.";
138
138
  const QUICK_EDIT_LIMIT = 4;
139
139
  const DROPDOWN_VIEWPORT_PADDING = 8;
140
+ const FONT_FAMILY_PATH_REGEX = /^typography\.fontFamily/i;
140
141
 
141
142
  function isHoverCapable() {
142
143
  if (typeof window === "undefined" || !window.matchMedia) return false;
@@ -443,6 +444,33 @@ function collectQuickRulePaths(target) {
443
444
  }).flatMap((rule) => rule.paths);
444
445
  }
445
446
 
447
+ function isHeadingElement(target) {
448
+ if (!target || !(target instanceof Element)) return false;
449
+ const tag = target.tagName?.toLowerCase?.() || "";
450
+ if (/^h[1-6]$/.test(tag)) return true;
451
+ if (target.getAttribute("role") === "heading") return true;
452
+ return false;
453
+ }
454
+
455
+ function hasMeaningfulText(target) {
456
+ if (!target || !(target instanceof Element)) return false;
457
+ const tag = target.tagName?.toLowerCase?.() || "";
458
+ if (["script", "style", "svg", "path", "defs", "symbol"].includes(tag)) return false;
459
+ const text = target.textContent || "";
460
+ return text.trim().length > 0;
461
+ }
462
+
463
+ function collectTypographyPathsForTarget(target) {
464
+ if (!target || !(target instanceof Element)) return [];
465
+ if (isHeadingElement(target)) {
466
+ return ["typography.fontFamilyHeadings"];
467
+ }
468
+ if (hasMeaningfulText(target)) {
469
+ return ["typography.fontFamilyBody"];
470
+ }
471
+ return [];
472
+ }
473
+
446
474
  function pathExistsInDesign(path, design) {
447
475
  if (!path || !design) return false;
448
476
  const segments = path.split(".");
@@ -455,7 +483,7 @@ function pathExistsInDesign(path, design) {
455
483
  return true;
456
484
  }
457
485
 
458
- function filterPathsByContext(target, paths) {
486
+ function filterPathsByContext(target, paths, alwaysAllow = new Set()) {
459
487
  if (!target || !paths.length) return paths;
460
488
  const isGlobal = target.matches("body, main");
461
489
  const isInForm = Boolean(target.closest("form, pds-form"));
@@ -468,6 +496,7 @@ function filterPathsByContext(target, paths) {
468
496
  const design = PDS?.currentConfig?.design || {};
469
497
 
470
498
  return paths.filter((path) => {
499
+ if (alwaysAllow.has(path)) return true;
471
500
  if (!theme.isDark && path.includes(DARK_MODE_PATH_MARKER)) return false;
472
501
  if (path.startsWith("typography.") && !isGlobal) return false;
473
502
  if (GLOBAL_LAYOUT_PATHS.has(path) && !isGlobal) return false;
@@ -768,7 +797,10 @@ function collectPathsFromComputedStyles(target) {
768
797
  const trimmed = value.trim();
769
798
 
770
799
  // Extract var refs from the value (handles var() with fallbacks)
771
- addVarSet(extractAllVarRefs(trimmed));
800
+ const directVarRefs = extractAllVarRefs(trimmed);
801
+ addVarSet(directVarRefs);
802
+ const hasDirectVarRefs = directVarRefs.size > 0;
803
+ if (hasDirectVarRefs) return;
772
804
 
773
805
  if (trimmed && valueToVars.has(trimmed)) {
774
806
  valueToVars.get(trimmed).forEach((varName) => addVarName(varName));
@@ -829,15 +861,18 @@ function collectQuickContext(target) {
829
861
  const byComputed = computed?.paths || [];
830
862
  const byRelations = collectPathsFromRelations(target);
831
863
  const byQuickRules = collectQuickRulePaths(target);
864
+ const byTypographyContext = collectTypographyPathsForTarget(target);
832
865
  const hints = computed?.hints || {};
833
866
  const debug = computed?.debug || { vars: [], paths: [] };
867
+ const alwaysAllow = new Set(byTypographyContext);
834
868
 
835
869
  // Prioritize quick rule paths first (selector-based), then computed/relations
836
870
  const filtered = filterPathsByContext(target, [
871
+ ...byTypographyContext,
837
872
  ...byQuickRules,
838
873
  ...byComputed,
839
874
  ...byRelations,
840
- ]);
875
+ ], alwaysAllow);
841
876
  if (!filtered.length) {
842
877
  return {
843
878
  paths: normalizePaths(DEFAULT_QUICK_PATHS),
@@ -860,6 +895,105 @@ function collectDrawerPaths(quickPaths) {
860
895
  return normalizePaths([...quickPaths, ...expanded]);
861
896
  }
862
897
 
898
+ function splitFontFamilyStack(value) {
899
+ if (typeof value !== "string") return [];
900
+ const input = value.trim();
901
+ if (!input) return [];
902
+ const parts = [];
903
+ let buffer = "";
904
+ let quote = null;
905
+ for (let i = 0; i < input.length; i += 1) {
906
+ const char = input[i];
907
+ if (quote) {
908
+ buffer += char;
909
+ if (char === quote && input[i - 1] !== "\\") {
910
+ quote = null;
911
+ }
912
+ continue;
913
+ }
914
+ if (char === '"' || char === "'") {
915
+ quote = char;
916
+ buffer += char;
917
+ continue;
918
+ }
919
+ if (char === ",") {
920
+ const token = buffer.trim();
921
+ if (token) parts.push(token);
922
+ buffer = "";
923
+ continue;
924
+ }
925
+ buffer += char;
926
+ }
927
+ const last = buffer.trim();
928
+ if (last) parts.push(last);
929
+ return parts;
930
+ }
931
+
932
+ function getPresetFontFamilyVariations() {
933
+ const presets = Object.values(PDS?.presets || {});
934
+ const seen = new Set();
935
+ const items = [];
936
+ const addItem = (fontFamily) => {
937
+ const normalized = String(fontFamily || "").trim().replace(/\s+/g, " ");
938
+ if (!normalized || seen.has(normalized)) return;
939
+ seen.add(normalized);
940
+ items.push({
941
+ id: normalized,
942
+ text: normalized,
943
+ style: `font-family: ${normalized}`,
944
+ });
945
+ };
946
+
947
+ presets.forEach((preset) => {
948
+ const typography = preset?.typography || {};
949
+ ["fontFamilyHeadings", "fontFamilyBody"].forEach((key) => {
950
+ const stack = typography[key];
951
+ if (typeof stack !== "string" || !stack.trim()) return;
952
+
953
+ addItem(stack);
954
+ const parts = splitFontFamilyStack(stack);
955
+ parts.forEach((part) => addItem(part));
956
+ for (let i = 1; i < parts.length; i += 1) {
957
+ addItem(parts.slice(i).join(", "));
958
+ }
959
+ });
960
+ });
961
+
962
+ return items;
963
+ }
964
+
965
+ function buildFontFamilyOmniboxSettings() {
966
+ const allItems = getPresetFontFamilyVariations();
967
+ const filterItems = (search) => {
968
+ const query = String(search || "").trim().toLowerCase();
969
+ if (!query) return allItems;
970
+ return allItems.filter((item) => item.text.toLowerCase().includes(query));
971
+ };
972
+
973
+ return {
974
+ hideCategory: true,
975
+ iconHandler: (item) => {
976
+
977
+ return "";
978
+ },
979
+ categories: {
980
+ FontFamilies: {
981
+ trigger: () => true,
982
+ getItems: (options) => filterItems(options?.search),
983
+ action: (options, ev) => {
984
+ const input = document.querySelector("[name='/typography/fontFamilyHeadings']");
985
+
986
+ if (input && input.tagName === "PDS-OMNIBOX") {
987
+ input.value = options?.text || "";
988
+
989
+ }
990
+ return options?.text || options?.id;
991
+ },
992
+ },
993
+ },
994
+ };
995
+ }
996
+
863
997
  function buildSchemaFromPaths(paths, design, hints = {}) {
864
998
  const schema = { type: "object", properties: {} };
865
999
  const uiSchema = {};
@@ -914,6 +1048,13 @@ function buildSchemaFromPaths(paths, design, hints = {}) {
914
1048
  if (!parent) {
915
1049
  parent = { type: "object", title: titleize(category), properties: {} };
916
1050
  schema.properties[category] = parent;
1051
+
1052
+ if (category === "colors") {
1053
+ uiSchema[`/${category}`] = {
1054
+ "ui:layout": "flex",
1055
+ "ui:layoutOptions": { wrap: true, gap: "sm" },
1056
+ };
1057
+ }
917
1058
  }
918
1059
 
919
1060
  let current = parent;
@@ -973,6 +1114,8 @@ function buildSchemaFromPaths(paths, design, hints = {}) {
973
1114
  uiEntry["ui:step"] = bounds.step;
974
1115
  } else if (isColorValue(value, path)) {
975
1116
  uiEntry["ui:widget"] = "input-color";
1117
+ } else if (FONT_FAMILY_PATH_REGEX.test(path) && schemaType === "string") {
1118
+ uiEntry["ui:widget"] = "font-family-omnibox";
976
1119
  }
977
1120
 
978
1121
  const isTextOrNumberInput =
@@ -1173,6 +1316,155 @@ async function applyPresetSelection(presetId) {
1173
1316
  await applyDesignPatch({});
1174
1317
  }
1175
1318
 
1319
+ function figmafyTokens(rawTokens) {
1320
+ const isPlainObject = (value) =>
1321
+ value !== null && typeof value === "object" && !Array.isArray(value);
1322
+
1323
+ const detectType = (path, key, value) => {
1324
+ const root = path[0];
1325
+
1326
+ if (root === "colors") {
1327
+ if (key === "scheme") return "string";
1328
+ return "color";
1329
+ }
1330
+
1331
+ if (root === "spacing" || root === "radius" || root === "borderWidths") {
1332
+ return "dimension";
1333
+ }
1334
+
1335
+ if (root === "typography") {
1336
+ const group = path[1];
1337
+ if (group === "fontFamily") return "fontFamily";
1338
+ if (group === "fontSize") return "fontSize";
1339
+ if (group === "fontWeight") return "fontWeight";
1340
+ if (group === "lineHeight") return "lineHeight";
1341
+ return "string";
1342
+ }
1343
+
1344
+ if (root === "shadows") return "shadow";
1345
+ if (root === "layout") return "dimension";
1346
+ if (root === "transitions") return "duration";
1347
+ if (root === "zIndex") return "number";
1348
+
1349
+ if (root === "icons") {
1350
+ if (key === "defaultSize" || path.includes("sizes")) {
1351
+ return "dimension";
1352
+ }
1353
+ return "string";
1354
+ }
1355
+
1356
+ if (typeof value === "number") {
1357
+ return "number";
1358
+ }
1359
+
1360
+ if (typeof value === "string") {
1361
+ if (/^\d+(\.\d+)?ms$/.test(value)) return "duration";
1362
+ if (/^\d+(\.\d+)?(px|rem|em|vh|vw|%)$/.test(value)) return "dimension";
1363
+
1364
+ if (
1365
+ /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(value) ||
1366
+ /^(rgb|rgba|hsl|hsla|oklab|lab)\(/.test(value)
1367
+ ) {
1368
+ return "color";
1369
+ }
1370
+ }
1371
+
1372
+ return undefined;
1373
+ };
1374
+
1375
+ const walk = (node, path = []) => {
1376
+ if (node == null) return node;
1377
+
1378
+ if (Array.isArray(node)) {
1379
+ return node.map((item, index) => walk(item, path.concat(String(index))));
1380
+ }
1381
+
1382
+ if (isPlainObject(node)) {
1383
+ if (
1384
+ Object.prototype.hasOwnProperty.call(node, "value") &&
1385
+ (Object.prototype.hasOwnProperty.call(node, "type") ||
1386
+ Object.keys(node).length === 1)
1387
+ ) {
1388
+ return node;
1389
+ }
1390
+
1391
+ const result = {};
1392
+ for (const [key, value] of Object.entries(node)) {
1393
+ result[key] = walk(value, path.concat(key));
1394
+ }
1395
+ return result;
1396
+ }
1397
+
1398
+ const key = path[path.length - 1] ?? "";
1399
+ const type = detectType(path, key, node);
1400
+ let value = node;
1401
+
1402
+ if (type === "number" && typeof value === "string") {
1403
+ const num = Number(value);
1404
+ if (!Number.isNaN(num)) value = num;
1405
+ }
1406
+
1407
+ return type ? { value, type } : { value };
1408
+ };
1409
+
1410
+ return walk(rawTokens, []);
1411
+ }
1412
+
1413
+ function downloadTextFile(content, filename, mimeType) {
1414
+ if (typeof document === "undefined") return;
1415
+ const blob = new Blob([content], { type: mimeType });
1416
+ const url = URL.createObjectURL(blob);
1417
+ const anchor = document.createElement("a");
1418
+ anchor.href = url;
1419
+ anchor.download = filename;
1420
+ anchor.click();
1421
+ URL.revokeObjectURL(url);
1422
+ }
1423
+
1424
+ function getLiveEditExportConfig() {
1425
+ const stored = getStoredConfig();
1426
+ const design = shallowClone(PDS?.currentConfig?.design || stored?.design || {});
1427
+ const preset = stored?.preset || PDS?.currentConfig?.preset || PDS?.currentPreset || null;
1428
+ return {
1429
+ preset,
1430
+ design,
1431
+ };
1432
+ }
1433
+
1434
+ function buildConfigModuleContent(config) {
1435
+ return `export const pdsConfig = ${JSON.stringify(config, null, 2)};\n\nexport default pdsConfig;\n`;
1436
+ }
1437
+
1438
+ async function exportFromLiveEdit(format) {
1439
+ try {
1440
+ if (format === "config") {
1441
+ const config = getLiveEditExportConfig();
1442
+ const content = buildConfigModuleContent(config);
1443
+ downloadTextFile(content, "pds.config.js", "text/javascript");
1444
+ await PDS?.toast?.("Exported config file", { type: "success" });
1445
+ return;
1446
+ }
1447
+
1448
+ if (format === "figma") {
1449
+ const Generator = await getGeneratorClass();
1450
+ const generator = Generator?.instance;
1451
+ if (!generator || typeof generator.generateTokens !== "function") {
1452
+ throw new Error("Token generator unavailable");
1453
+ }
1454
+
1455
+ const rawTokens = generator.generateTokens();
1456
+ const figmaTokens = figmafyTokens(rawTokens);
1457
+ const content = JSON.stringify(figmaTokens, null, 2);
1458
+ downloadTextFile(content, "design-tokens.figma.json", "application/json");
1459
+ await PDS?.toast?.("Exported Figma tokens", { type: "success" });
1460
+ return;
1461
+ }
1462
+ } catch (error) {
1463
+ console.warn("[pds-live-edit] Export failed", error);
1464
+ await PDS?.toast?.("Export failed", { type: "error" });
1465
+ }
1466
+ }
1467
+
1176
1468
  function setFormSchemas(form, schema, uiSchema, design) {
1177
1469
  form.jsonSchema = schema;
1178
1470
  form.uiSchema = uiSchema;
@@ -1181,7 +1473,13 @@ function setFormSchemas(form, schema, uiSchema, design) {
1181
1473
 
1182
1474
  async function buildForm(paths, design, onSubmit, onUndo, hints = {}) {
1183
1475
  const { schema, uiSchema } = buildSchemaFromPaths(paths, design, hints);
1476
+
1477
+ if (!customElements.get("pds-form")) {
1478
+ await customElements.whenDefined("pds-form");
1479
+ }
1480
+
1184
1481
  const form = document.createElement("pds-form");
1482
+ const fontFamilyOmniboxSettings = buildFontFamilyOmniboxSettings();
1185
1483
  form.setAttribute("hide-actions", "");
1186
1484
  form.options = {
1187
1485
  layouts: {
@@ -1191,6 +1489,62 @@ async function buildForm(paths, design, onSubmit, onUndo, hints = {}) {
1191
1489
  rangeOutput: true,
1192
1490
  },
1193
1491
  };
1492
+ form.defineRenderer(
1493
+ "font-family-omnibox",
1494
+ ({ id, path, value, attrs, set }) => {
1495
+ const resolveSelectedValue = (options, actionResult) => {
1496
+ if (typeof actionResult === "string" && actionResult.trim()) {
1497
+ return actionResult;
1498
+ }
1499
+ const fromText = String(options?.text || "").trim();
1500
+ if (fromText) return fromText;
1501
+ return String(options?.id || "").trim();
1502
+ };
1503
+
1504
+ const categories = Object.fromEntries(
1505
+ Object.entries(fontFamilyOmniboxSettings.categories || {}).map(
1506
+ ([categoryName, categoryConfig]) => {
1507
+ const originalAction = categoryConfig?.action;
1508
+ return [
1509
+ categoryName,
1510
+ {
1511
+ ...categoryConfig,
1512
+ action: (options) => {
1513
+ const actionResult =
1514
+ typeof originalAction === "function"
1515
+ ? originalAction(options)
1516
+ : undefined;
1517
+ const selected = resolveSelectedValue(options, actionResult);
1518
+ if (selected) {
1519
+ set(selected);
1520
+ }
1521
+ return actionResult;
1522
+ },
1523
+ },
1524
+ ];
1525
+ }
1526
+ )
1527
+ );
1528
+
1529
+ const omnibox = document.createElement("pds-omnibox");
1530
+ omnibox.id = id;
1531
+ omnibox.setAttribute("name", path);
1532
+ omnibox.setAttribute("item-grid", "0 1fr");
1533
+ omnibox.setAttribute("placeholder", attrs?.placeholder || "Select a font family");
1534
+ omnibox.value = value ?? "";
1535
+ omnibox.settings = {
1536
+ ...fontFamilyOmniboxSettings,
1537
+ categories,
1538
+ };
1539
+ omnibox.addEventListener("input", (event) => {
1540
+ set(event?.target?.value ?? omnibox.value ?? "");
1541
+ });
1542
+ omnibox.addEventListener("change", (event) => {
1543
+ set(event?.target?.value ?? omnibox.value ?? "");
1544
+ });
1545
+ return omnibox;
1546
+ }
1547
+ );
1194
1548
  form.addEventListener("pw:submit", onSubmit);
1195
1549
  const values = shallowClone(design || {});
1196
1550
  Object.keys(ENUM_FIELD_OPTIONS).forEach((path) => {
@@ -1208,12 +1562,6 @@ async function buildForm(paths, design, onSubmit, onUndo, hints = {}) {
1208
1562
  });
1209
1563
  setFormSchemas(form, schema, uiSchema, values);
1210
1564
 
1211
- if (!customElements.get("pds-form")) {
1212
- customElements.whenDefined("pds-form").then(() => {
1213
- setFormSchemas(form, schema, uiSchema, values);
1214
- });
1215
- }
1216
-
1217
1565
  // Apply button (will trigger form submit programmatically)
1218
1566
  const applyBtn = document.createElement("button");
1219
1567
  applyBtn.className = "btn-primary btn-sm";
@@ -1821,6 +2169,65 @@ class PdsLiveEdit extends HTMLElement {
1821
2169
  const themeToggle = document.createElement("pds-theme");
1822
2170
  themeCard.appendChild(themeToggle);
1823
2171
 
2172
+ const exportCard = document.createElement("section");
2173
+ exportCard.className = "card surface-elevated stack-sm";
2174
+
2175
+ const exportTitle = document.createElement("h4");
2176
+ exportTitle.textContent = "Export";
2177
+ exportCard.appendChild(exportTitle);
2178
+
2179
+ const exportNav = document.createElement("nav");
2180
+ exportNav.setAttribute("data-dropdown", "");
2181
+ exportNav.setAttribute("data-mode", "auto");
2182
+
2183
+ const exportButton = document.createElement("button");
2184
+ exportButton.className = "btn-primary";
2185
+ const exportIcon = document.createElement("pds-icon");
2186
+ exportIcon.setAttribute("icon", "download");
2187
+ exportIcon.setAttribute("size", "sm");
2188
+ const exportLabel = document.createElement("span");
2189
+ exportLabel.textContent = "Download";
2190
+ const exportCaret = document.createElement("pds-icon");
2191
+ exportCaret.setAttribute("icon", "caret-down");
2192
+ exportCaret.setAttribute("size", "sm");
2193
+ exportButton.append(exportIcon, exportLabel, exportCaret);
2194
+
2195
+ const exportMenu = document.createElement("menu");
2196
+
2197
+ const configItem = document.createElement("li");
2198
+ const configLink = document.createElement("a");
2199
+ configLink.href = "#";
2200
+ configLink.addEventListener("click", async (event) => {
2201
+ event.preventDefault();
2202
+ await this._handleExport("config");
2203
+ });
2204
+ const configIcon = document.createElement("pds-icon");
2205
+ configIcon.setAttribute("icon", "file-js");
2206
+ configIcon.setAttribute("size", "sm");
2207
+ const configLabel = document.createElement("span");
2208
+ configLabel.textContent = "Config File";
2209
+ configLink.append(configIcon, configLabel);
2210
+ configItem.appendChild(configLink);
2211
+
2212
+ const figmaItem = document.createElement("li");
2213
+ const figmaLink = document.createElement("a");
2214
+ figmaLink.href = "#";
2215
+ figmaLink.addEventListener("click", async (event) => {
2216
+ event.preventDefault();
2217
+ await this._handleExport("figma");
2218
+ });
2219
+ const figmaIcon = document.createElement("pds-icon");
2220
+ figmaIcon.setAttribute("icon", "brackets-curly");
2221
+ figmaIcon.setAttribute("size", "sm");
2222
+ const figmaLabel = document.createElement("span");
2223
+ figmaLabel.textContent = "Figma Tokens (JSON)";
2224
+ figmaLink.append(figmaIcon, figmaLabel);
2225
+ figmaItem.appendChild(figmaLink);
2226
+
2227
+ exportMenu.append(configItem, figmaItem);
2228
+ exportNav.append(exportButton, exportMenu);
2229
+ exportCard.appendChild(exportNav);
2230
+
1824
2231
  const searchCard = document.createElement("section");
1825
2232
  searchCard.className = "card surface-elevated stack-sm";
1826
2233
 
@@ -1867,6 +2274,7 @@ class PdsLiveEdit extends HTMLElement {
1867
2274
 
1868
2275
  content.appendChild(presetCard);
1869
2276
  content.appendChild(themeCard);
2277
+ content.appendChild(exportCard);
1870
2278
  content.appendChild(searchCard);
1871
2279
 
1872
2280
  this._drawer.replaceChildren(header, content);
@@ -1878,6 +2286,10 @@ class PdsLiveEdit extends HTMLElement {
1878
2286
  }
1879
2287
  }
1880
2288
 
2289
+ async _handleExport(format) {
2290
+ await exportFromLiveEdit(format);
2291
+ }
2292
+
1881
2293
  async _handleFormSubmit(event, form) {
1882
2294
  if (!form || typeof form.getValuesFlat !== "function") return;
1883
2295
 
@@ -80,6 +80,30 @@ function enhanceDropdown(elem) {
80
80
  trigger.setAttribute("aria-expanded", "false");
81
81
  }
82
82
 
83
+ const measureMenuSize = () => {
84
+ const previousStyle = menu.getAttribute("style");
85
+ menu.style.visibility = "hidden";
86
+ menu.style.display = "inline-block";
87
+ menu.style.pointerEvents = "none";
88
+
89
+ const rect = menu.getBoundingClientRect();
90
+ const width = Math.max(menu.offsetWidth || 0, menu.scrollWidth || 0, rect.width || 0, 1);
91
+ const height = Math.max(
92
+ menu.offsetHeight || 0,
93
+ menu.scrollHeight || 0,
94
+ rect.height || 0,
95
+ 1,
96
+ );
97
+
98
+ if (previousStyle === null) {
99
+ menu.removeAttribute("style");
100
+ } else {
101
+ menu.setAttribute("style", previousStyle);
102
+ }
103
+
104
+ return { width, height };
105
+ };
106
+
83
107
  const resolveDirection = () => {
84
108
  const mode = (
85
109
  elem.getAttribute("data-direction") ||
@@ -88,18 +112,15 @@ function enhanceDropdown(elem) {
88
112
  "auto"
89
113
  ).toLowerCase();
90
114
  if (mode === "up" || mode === "down") return mode;
91
- const rect = elem.getBoundingClientRect();
92
- const menuRect = menu?.getBoundingClientRect?.() || { height: 0 };
93
- const menuHeight = Math.max(
94
- menu?.offsetHeight || 0,
95
- menu?.scrollHeight || 0,
96
- menuRect.height || 0,
97
- 200,
98
- );
99
- const spaceBelow = Math.max(0, window.innerHeight - rect.bottom);
100
- const spaceAbove = Math.max(0, rect.top);
101
- if (spaceBelow >= menuHeight) return "down";
102
- if (spaceAbove >= menuHeight) return "up";
115
+ const anchorRect = (trigger || elem).getBoundingClientRect();
116
+ const { height: menuHeight } = measureMenuSize();
117
+ const spaceBelow = Math.max(0, window.innerHeight - anchorRect.bottom);
118
+ const spaceAbove = Math.max(0, anchorRect.top);
119
+ const fitsDown = spaceBelow >= menuHeight;
120
+ const fitsUp = spaceAbove >= menuHeight;
121
+ if (fitsDown && !fitsUp) return "down";
122
+ if (fitsUp && !fitsDown) return "up";
123
+ if (fitsDown && fitsUp) return "down";
103
124
  return spaceAbove > spaceBelow ? "up" : "down";
104
125
  };
105
126
 
@@ -117,19 +138,17 @@ function enhanceDropdown(elem) {
117
138
  ) {
118
139
  return align === "start" ? "left" : align === "end" ? "right" : align;
119
140
  }
120
- const rect = elem.getBoundingClientRect();
121
- const menuRect = menu?.getBoundingClientRect?.() || { width: 0 };
122
- const menuWidth = Math.max(
123
- menu?.offsetWidth || 0,
124
- menu?.scrollWidth || 0,
125
- menuRect.width || 0,
126
- 240,
127
- );
128
- const spaceRight = Math.max(0, window.innerWidth - rect.left);
129
- const spaceLeft = Math.max(0, rect.right);
130
- if (spaceRight >= menuWidth) return "left";
131
- if (spaceLeft >= menuWidth) return "right";
132
- return spaceLeft > spaceRight ? "right" : "left";
141
+ const anchorRect = (trigger || elem).getBoundingClientRect();
142
+ const { width: menuWidth } = measureMenuSize();
143
+ const spaceForLeftAligned = Math.max(0, window.innerWidth - anchorRect.left);
144
+ const spaceForRightAligned = Math.max(0, anchorRect.right);
145
+ const fitsLeft = spaceForLeftAligned >= menuWidth;
146
+ const fitsRight = spaceForRightAligned >= menuWidth;
147
+
148
+ if (fitsLeft && !fitsRight) return "left";
149
+ if (fitsRight && !fitsLeft) return "right";
150
+ if (fitsLeft && fitsRight) return "left";
151
+ return spaceForRightAligned > spaceForLeftAligned ? "right" : "left";
133
152
  };
134
153
 
135
154
  // Store click handler reference for cleanup