@apliteni/apliteni-ui 0.33.1 → 0.34.0

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.
@@ -20,7 +20,7 @@ var cx = (...a) => a.filter(Boolean).join(" ");
20
20
  function Button({
21
21
  variant = "secondary",
22
22
  size = "md",
23
- icon: icon3,
23
+ icon: icon4,
24
24
  iconRight,
25
25
  iconOnly,
26
26
  block,
@@ -38,7 +38,7 @@ function Button({
38
38
  iconOnly && "ui-btn--icon"
39
39
  );
40
40
  const labelled = rest["aria-label"] != null || rest["aria-labelledby"] != null;
41
- const fallback = typeof children === "string" && children.trim() ? children.trim() : icon3;
41
+ const fallback = typeof children === "string" && children.trim() ? children.trim() : icon4;
42
42
  const named = iconOnly && !labelled && fallback ? { "aria-label": fallback, title: rest.title ?? fallback } : {};
43
43
  return /* @__PURE__ */ jsxs(
44
44
  "button",
@@ -51,7 +51,7 @@ function Button({
51
51
  ...rest,
52
52
  ...named,
53
53
  children: [
54
- icon3 && /* @__PURE__ */ jsx2(Icon, { name: icon3 }),
54
+ icon4 && /* @__PURE__ */ jsx2(Icon, { name: icon4 }),
55
55
  !iconOnly && children != null && /* @__PURE__ */ jsx2("span", { children }),
56
56
  iconRight && /* @__PURE__ */ jsx2(Icon, { name: iconRight }),
57
57
  busy && /* @__PURE__ */ jsxs("span", { className: "ui-btn__bars", children: [
@@ -87,8 +87,8 @@ function Card({ title, sub, level = 2, children }) {
87
87
  import { useId } from "react";
88
88
  import { Fragment, jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
89
89
  var GLYPH = { up: "arrowUp", down: "arrowDown", flat: "minus" };
90
- var directionOf = (text) => {
91
- const t = text.trim();
90
+ var directionOf = (text2) => {
91
+ const t = text2.trim();
92
92
  return /^[-−–(]/.test(t) ? "down" : /^\+/.test(t) ? "up" : "flat";
93
93
  };
94
94
  var hasChange = (d) => !!d && d.value != null && d.value !== "";
@@ -683,10 +683,10 @@ function Pagination({
683
683
  was.focus();
684
684
  return;
685
685
  }
686
- const strip = [...nav.current.querySelectorAll(".ui-pager__step")];
687
- const from2 = strip.indexOf(was);
688
- for (let d = 1; d < strip.length; d += 1) {
689
- const near = [strip[from2 - d], strip[from2 + d]].find((el) => el && !el.disabled);
686
+ const strip2 = [...nav.current.querySelectorAll(".ui-pager__step")];
687
+ const from2 = strip2.indexOf(was);
688
+ for (let d = 1; d < strip2.length; d += 1) {
689
+ const near = [strip2[from2 - d], strip2[from2 + d]].find((el) => el && !el.disabled);
690
690
  if (near) {
691
691
  near.focus();
692
692
  return;
@@ -1024,12 +1024,12 @@ function Denied({
1024
1024
  title = "You don\u2019t have access",
1025
1025
  sub,
1026
1026
  need,
1027
- icon: icon3 = "lock",
1027
+ icon: icon4 = "lock",
1028
1028
  className,
1029
1029
  children
1030
1030
  }) {
1031
1031
  return /* @__PURE__ */ jsxs9("div", { className: cx4("ui-denied", className), children: [
1032
- /* @__PURE__ */ jsx11("div", { className: "ui-denied__seal", children: /* @__PURE__ */ jsx11(Icon, { name: icon3 }) }),
1032
+ /* @__PURE__ */ jsx11("div", { className: "ui-denied__seal", children: /* @__PURE__ */ jsx11(Icon, { name: icon4 }) }),
1033
1033
  /* @__PURE__ */ jsx11("div", { className: "ui-denied__title", children: title }),
1034
1034
  sub && /* @__PURE__ */ jsx11("div", { className: "ui-denied__sub", children: sub }),
1035
1035
  need && /* @__PURE__ */ jsxs9("div", { className: "ui-denied__need", children: [
@@ -1040,10 +1040,440 @@ function Denied({
1040
1040
  ] });
1041
1041
  }
1042
1042
 
1043
+ // src/Dropdown.tsx
1044
+ import {
1045
+ Fragment as Fragment3,
1046
+ useCallback as useCallback2,
1047
+ useEffect as useEffect4,
1048
+ useId as useId5,
1049
+ useRef as useRef6,
1050
+ useState as useState5
1051
+ } from "react";
1052
+ import { icon as icon3, dropdownMatch, dropdownFiltering } from "@apliteni/apliteni-ui";
1053
+ import { Fragment as Fragment4, jsx as jsx12, jsxs as jsxs10 } from "react/jsx-runtime";
1054
+ var cx5 = (...a) => a.filter(Boolean).join(" ");
1055
+ var isRow = (it) => Boolean(it) && it !== "---" && !it.separator;
1056
+ var Glyph2 = ({ name, className, hidden }) => /* @__PURE__ */ jsx12(
1057
+ "span",
1058
+ {
1059
+ className,
1060
+ "aria-hidden": hidden ? "true" : void 0,
1061
+ dangerouslySetInnerHTML: { __html: icon3(name) }
1062
+ }
1063
+ );
1064
+ function RowBadge({ badge }) {
1065
+ const text2 = typeof badge === "string" ? badge : badge.text;
1066
+ const given = typeof badge === "string" ? "" : badge.tone || "";
1067
+ const tone = given || (/^live$/i.test(String(text2)) ? "live" : "neutral");
1068
+ return /* @__PURE__ */ jsx12("span", { className: `ui-dropdown__badge is-${tone}`, children: text2 });
1069
+ }
1070
+ var keyOf = (it) => it.value != null ? `v:${it.value}` : `l:${it.label}`;
1071
+ var itemsIn = (panel) => Array.from(panel?.querySelectorAll("[data-dd-item]") ?? []).filter((el) => el.getAttribute("aria-disabled") !== "true" && !el.hidden);
1072
+ var openNow = /* @__PURE__ */ new Set();
1073
+ var composing = (e) => e.isComposing || e.keyCode === 229;
1074
+ var SEARCH_DEFAULTS = {
1075
+ placeholder: "Search",
1076
+ empty: "No match for \u201C{q}\u201D",
1077
+ hint: "Check the spelling, or try fewer letters."
1078
+ };
1079
+ function Dropdown({
1080
+ label,
1081
+ value,
1082
+ placeholder = "Select\u2026",
1083
+ variant,
1084
+ items,
1085
+ sections,
1086
+ header,
1087
+ footer,
1088
+ triggerContent,
1089
+ triggerClass = "",
1090
+ chevron = true,
1091
+ align = "start",
1092
+ direction = "down",
1093
+ scroll = false,
1094
+ search = false,
1095
+ ariaLabel,
1096
+ id,
1097
+ panelClass = "",
1098
+ open: openProp,
1099
+ defaultOpen = false,
1100
+ onOpenChange,
1101
+ onSelect,
1102
+ row
1103
+ }) {
1104
+ const [selfOpen, setSelfOpen] = useState5(defaultOpen);
1105
+ const open = openProp ?? selfOpen;
1106
+ const [pick, setPick] = useState5(null);
1107
+ const [query, setQuery] = useState5(() => search && search !== true ? search.query || "" : "");
1108
+ const [activeKey, setActiveKey] = useState5(null);
1109
+ const root = useRef6(null);
1110
+ const panel = useRef6(null);
1111
+ const trigger = useRef6(null);
1112
+ const field = useRef6(null);
1113
+ const landOn = useRef6(null);
1114
+ const at = useRef6("");
1115
+ const auto = useId5().replace(/:/g, "");
1116
+ const uid = id ?? auto;
1117
+ const setOpen = useCallback2((next) => {
1118
+ if (openProp === void 0) setSelfOpen(next);
1119
+ onOpenChange?.(next);
1120
+ }, [openProp, onOpenChange]);
1121
+ const close = useRef6(setOpen);
1122
+ useIsoLayoutEffect(() => {
1123
+ close.current = setOpen;
1124
+ });
1125
+ const sx = search ? { ...SEARCH_DEFAULTS, ...strip(search === true ? {} : search) } : null;
1126
+ const entries = sections?.length ? sections.flatMap((s) => s.items || []) : items || [];
1127
+ const rows = entries.filter(isRow);
1128
+ const isSelect = variant === "select" || variant == null && rows.some((it) => it.selected || it.value != null);
1129
+ const listRole = isSelect ? "listbox" : "menu";
1130
+ const asOption = isSelect || Boolean(sx);
1131
+ const name = ariaLabel || (label ? String(label).replace(/:\s*$/, "") : "") || "Options";
1132
+ const given = rows.find((it) => it.selected);
1133
+ const givenKey = given ? keyOf(given) : null;
1134
+ const pickedKey = pick && pick.against === givenKey ? pick.key : null;
1135
+ useEffect4(() => {
1136
+ if (pick && pick.against !== givenKey) setPick(null);
1137
+ }, [givenKey, pick]);
1138
+ const picked = pickedKey != null ? rows.find((it) => keyOf(it) === pickedKey) : void 0;
1139
+ const shown = value != null ? value : isSelect ? picked?.label ?? given?.label : null;
1140
+ const selectedOf = (it) => pickedKey != null ? it.disabled ? !!it.selected : keyOf(it) === pickedKey : !!it.selected;
1141
+ const hiddenBy = (it) => Boolean(sx) && !dropdownMatch(it.label, query);
1142
+ const filtering = Boolean(sx) && dropdownFiltering(query);
1143
+ const visible = rows.filter((it) => !hiddenBy(it));
1144
+ const ring = visible.filter((it) => !it.disabled);
1145
+ const active = ring.find((it) => keyOf(it) === activeKey) ?? ring[0];
1146
+ const rowIds = /* @__PURE__ */ new Map();
1147
+ rows.forEach((it, i) => rowIds.set(it, `${uid}-opt-${i}`));
1148
+ const listId = `${uid}-list`;
1149
+ useEffect4(() => {
1150
+ if (!open) return;
1151
+ const mine = () => close.current(false);
1152
+ for (const other of [...openNow]) other();
1153
+ openNow.add(mine);
1154
+ return () => {
1155
+ openNow.delete(mine);
1156
+ };
1157
+ }, [open]);
1158
+ useEffect4(() => {
1159
+ if (!open) return;
1160
+ const doc = root.current?.ownerDocument ?? document;
1161
+ const onClick = (e) => {
1162
+ if (!root.current?.contains(e.target)) close.current(false);
1163
+ };
1164
+ const onKey2 = (e) => {
1165
+ if (e.key !== "Escape" || composing(e)) return;
1166
+ close.current(false);
1167
+ trigger.current?.focus();
1168
+ };
1169
+ doc.addEventListener("click", onClick);
1170
+ doc.addEventListener("keydown", onKey2);
1171
+ return () => {
1172
+ doc.removeEventListener("click", onClick);
1173
+ doc.removeEventListener("keydown", onKey2);
1174
+ };
1175
+ }, [open]);
1176
+ useIsoLayoutEffect(() => {
1177
+ const want = landOn.current;
1178
+ landOn.current = null;
1179
+ if (!open) return;
1180
+ if (sx) {
1181
+ setQuery("");
1182
+ const enabled = rows.filter((it) => !it.disabled);
1183
+ const start = enabled.find(selectedOf) ?? enabled[0];
1184
+ setActiveKey(start ? keyOf(start) : null);
1185
+ if (panel.current) {
1186
+ panel.current.style.minWidth = "";
1187
+ if (panel.current.offsetWidth) panel.current.style.minWidth = `${panel.current.offsetWidth}px`;
1188
+ }
1189
+ field.current?.focus();
1190
+ return;
1191
+ }
1192
+ if (want == null) return;
1193
+ const els = itemsIn(panel.current);
1194
+ if (!els.length) return;
1195
+ const sel = els.findIndex((el) => el.getAttribute("aria-selected") === "true");
1196
+ const to = want === "selected" ? sel >= 0 ? sel : 0 : want;
1197
+ (els[to] || els[0]).focus();
1198
+ }, [open]);
1199
+ useIsoLayoutEffect(() => {
1200
+ if (!sx || !open || !active) return;
1201
+ const el = panel.current?.querySelector("[data-dd-item].is-active");
1202
+ const list = el?.closest(".ui-dropdown__list");
1203
+ if (!el || !list || !list.clientHeight) return;
1204
+ const top = el.offsetTop - list.offsetTop;
1205
+ if (top < list.scrollTop) list.scrollTop = top;
1206
+ else if (top + el.offsetHeight > list.scrollTop + list.clientHeight) {
1207
+ list.scrollTop = top + el.offsetHeight - list.clientHeight;
1208
+ }
1209
+ });
1210
+ const choose = (item, e) => {
1211
+ if (item.disabled) {
1212
+ e.preventDefault();
1213
+ return;
1214
+ }
1215
+ if (isSelect) setPick({ key: keyOf(item), against: givenKey });
1216
+ onSelect?.(item.value, item);
1217
+ setOpen(false);
1218
+ trigger.current?.focus();
1219
+ };
1220
+ const onKeyDown = (e) => {
1221
+ const onTrigger = e.target === trigger.current;
1222
+ const onField = e.target === field.current;
1223
+ if (onField && composing(e)) return;
1224
+ if (sx && open) {
1225
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
1226
+ e.preventDefault();
1227
+ if (!ring.length) return;
1228
+ const i = active ? ring.indexOf(active) : -1;
1229
+ const next = e.key === "ArrowDown" ? (i + 1) % ring.length : i <= 0 ? ring.length - 1 : i - 1;
1230
+ setActiveKey(keyOf(ring[next]));
1231
+ return;
1232
+ }
1233
+ if (onField && e.key === "Enter") {
1234
+ e.preventDefault();
1235
+ panel.current?.querySelector("[data-dd-item].is-active")?.click();
1236
+ return;
1237
+ }
1238
+ if (onField && (e.key === "Home" || e.key === "End")) return;
1239
+ }
1240
+ if ((e.key === "ArrowDown" || e.key === "ArrowUp") && (onTrigger || open)) {
1241
+ e.preventDefault();
1242
+ if (!open) {
1243
+ landOn.current = e.key === "ArrowDown" ? 0 : "selected";
1244
+ setOpen(true);
1245
+ return;
1246
+ }
1247
+ const els = itemsIn(panel.current);
1248
+ if (!els.length) return;
1249
+ const i = els.indexOf(document.activeElement);
1250
+ const next = e.key === "ArrowDown" ? (i + 1) % els.length : (i - 1 + els.length) % els.length;
1251
+ els[next < 0 ? 0 : next]?.focus();
1252
+ } else if (e.key === "Home" && open) {
1253
+ e.preventDefault();
1254
+ itemsIn(panel.current)[0]?.focus();
1255
+ } else if (e.key === "End" && open) {
1256
+ e.preventDefault();
1257
+ const els = itemsIn(panel.current);
1258
+ els[els.length - 1]?.focus();
1259
+ } else if ((e.key === "Enter" || e.key === " ") && open && e.target.matches?.("[data-dd-item]")) {
1260
+ e.preventDefault();
1261
+ e.target.click();
1262
+ } else if (e.key === "Escape" && open) {
1263
+ e.preventDefault();
1264
+ e.stopPropagation();
1265
+ setOpen(false);
1266
+ trigger.current?.focus();
1267
+ } else if (e.key === "Tab" && open) {
1268
+ setOpen(false);
1269
+ }
1270
+ };
1271
+ const onMouseMove = (e) => {
1272
+ if (!sx) return;
1273
+ const here = `${e.clientX},${e.clientY}`;
1274
+ if (here === at.current) return;
1275
+ at.current = here;
1276
+ const el = e.target.closest?.("[data-dd-item]");
1277
+ if (!el || el.getAttribute("aria-disabled") === "true" || el.classList.contains("is-active")) return;
1278
+ const item = rows.find((it) => rowIds.get(it) === el.id);
1279
+ if (item) setActiveKey(keyOf(item));
1280
+ };
1281
+ function renderRow(entry, key) {
1282
+ if (!isRow(entry)) {
1283
+ return /* @__PURE__ */ jsx12("div", { className: "ui-dropdown__sep", role: "separator", hidden: filtering || void 0 }, key);
1284
+ }
1285
+ const disabled = !!entry.disabled;
1286
+ const selected = selectedOf(entry);
1287
+ const asLink = !!entry.href && !disabled && !asOption;
1288
+ const props = {
1289
+ className: cx5(
1290
+ "ui-dropdown__item",
1291
+ selected && "is-selected",
1292
+ disabled && "is-disabled",
1293
+ entry.danger && "is-danger",
1294
+ Boolean(sx) && open && entry === active && "is-active"
1295
+ ),
1296
+ "data-dd-item": "",
1297
+ role: asOption ? "option" : "menuitem",
1298
+ tabIndex: -1,
1299
+ ...entry.value != null ? { "data-value": String(entry.value) } : null,
1300
+ ...asOption ? { "aria-selected": selected } : null,
1301
+ ...disabled ? { "aria-disabled": true } : null,
1302
+ ...asLink ? { href: entry.href, ...entry.target ? { target: entry.target } : null } : null,
1303
+ ...sx ? { id: rowIds.get(entry), hidden: hiddenBy(entry) || void 0 } : null,
1304
+ onClick: (e) => choose(entry, e),
1305
+ children: /* @__PURE__ */ jsxs10(Fragment4, { children: [
1306
+ entry.icon && /* @__PURE__ */ jsx12(Glyph2, { name: entry.icon, className: "ui-dropdown__ic" }),
1307
+ /* @__PURE__ */ jsxs10("span", { className: "ui-dropdown__main", children: [
1308
+ /* @__PURE__ */ jsx12("span", { className: "ui-dropdown__label", children: entry.label }),
1309
+ entry.description && /* @__PURE__ */ jsx12("span", { className: "ui-dropdown__desc", children: entry.description })
1310
+ ] }),
1311
+ entry.badge ? /* @__PURE__ */ jsx12(RowBadge, { badge: entry.badge }) : null,
1312
+ asOption && /* @__PURE__ */ jsx12(Glyph2, { name: "check", className: "ui-dropdown__tick", hidden: true })
1313
+ ] })
1314
+ };
1315
+ if (row) return /* @__PURE__ */ jsx12(Fragment3, { children: row(entry, props) }, key);
1316
+ const Tag = asLink ? "a" : "div";
1317
+ return /* @__PURE__ */ jsx12(Tag, { ...props }, key);
1318
+ }
1319
+ const body = sections?.length ? sections.map((section, si) => {
1320
+ const gone = filtering && !(section.items || []).some((it) => isRow(it) && !hiddenBy(it));
1321
+ return /* @__PURE__ */ jsxs10(
1322
+ "div",
1323
+ {
1324
+ className: "ui-dropdown__section",
1325
+ role: "group",
1326
+ "aria-label": section.label || void 0,
1327
+ hidden: gone || void 0,
1328
+ children: [
1329
+ section.label && /* @__PURE__ */ jsx12("div", { className: "ui-dropdown__group", role: "presentation", children: section.label }),
1330
+ (section.items || []).map((entry, i) => renderRow(entry, `s${si}-${i}`))
1331
+ ]
1332
+ },
1333
+ section.label ?? `s${si}`
1334
+ );
1335
+ }) : (items || []).map((entry, i) => renderRow(entry, `i${i}`));
1336
+ const cap = scroll && scroll !== true ? { maxHeight: typeof scroll === "number" ? `${scroll}px` : scroll } : void 0;
1337
+ const none = sx && filtering && !visible.length ? /* @__PURE__ */ jsxs10(Fragment4, { children: [
1338
+ /* @__PURE__ */ jsx12("span", { className: "ui-dropdown__none-title", children: sx.empty.replace("{q}", () => query.trim()) }),
1339
+ /* @__PURE__ */ jsx12("span", { className: "ui-dropdown__none-hint", children: sx.hint })
1340
+ ] }) : null;
1341
+ return /* @__PURE__ */ jsxs10("div", { className: cx5("ui-dropdown", open && "open"), id, ref: root, onKeyDown, children: [
1342
+ /* @__PURE__ */ jsxs10(
1343
+ "button",
1344
+ {
1345
+ type: "button",
1346
+ className: cx5("ui-dropdown__trigger", triggerClass),
1347
+ "data-dropdown-trigger": "",
1348
+ "aria-haspopup": sx ? "dialog" : listRole,
1349
+ "aria-expanded": open,
1350
+ "aria-label": ariaLabel && triggerContent != null ? ariaLabel : void 0,
1351
+ ref: trigger,
1352
+ onClick: (e) => {
1353
+ e.stopPropagation();
1354
+ setOpen(!open);
1355
+ },
1356
+ children: [
1357
+ triggerContent != null ? triggerContent : /* @__PURE__ */ jsxs10(Fragment4, { children: [
1358
+ label && /* @__PURE__ */ jsx12("span", { className: "ui-dropdown__pre", children: label }),
1359
+ /* @__PURE__ */ jsx12("span", { className: "ui-dropdown__value", children: shown != null ? shown : placeholder })
1360
+ ] }),
1361
+ chevron && /* @__PURE__ */ jsx12("span", { className: "ui-dropdown__chevron", "aria-hidden": "true" })
1362
+ ]
1363
+ }
1364
+ ),
1365
+ /* @__PURE__ */ jsxs10(
1366
+ "div",
1367
+ {
1368
+ className: cx5(
1369
+ "ui-dropdown__panel",
1370
+ align === "end" && "is-end",
1371
+ direction === "up" && "is-up",
1372
+ Boolean(scroll) && !sx && "is-scroll",
1373
+ Boolean(sx) && "ui-dropdown__panel--search",
1374
+ panelClass
1375
+ ),
1376
+ "data-dropdown-panel": "",
1377
+ role: sx ? "dialog" : listRole,
1378
+ "aria-label": sx ? name : ariaLabel,
1379
+ style: sx ? void 0 : cap,
1380
+ ref: panel,
1381
+ onClick: (e) => e.stopPropagation(),
1382
+ onMouseMove,
1383
+ children: [
1384
+ header,
1385
+ sx ? /* @__PURE__ */ jsxs10(Fragment4, { children: [
1386
+ /* @__PURE__ */ jsxs10("div", { className: "ui-dropdown__search", children: [
1387
+ /* @__PURE__ */ jsx12(Glyph2, { name: "search", className: "ui-dropdown__search-ic", hidden: true }),
1388
+ /* @__PURE__ */ jsx12(
1389
+ "input",
1390
+ {
1391
+ className: "ui-dropdown__search-input",
1392
+ type: "text",
1393
+ role: "combobox",
1394
+ "aria-autocomplete": "list",
1395
+ "aria-expanded": true,
1396
+ "aria-controls": listId,
1397
+ "aria-label": sx.label || `Search ${name}`,
1398
+ "aria-activedescendant": open && active ? rowIds.get(active) : void 0,
1399
+ placeholder: sx.placeholder,
1400
+ autoComplete: "off",
1401
+ spellCheck: false,
1402
+ "data-dd-search": "",
1403
+ value: query,
1404
+ ref: field,
1405
+ onChange: (e) => {
1406
+ setQuery(e.target.value);
1407
+ setActiveKey(null);
1408
+ }
1409
+ }
1410
+ )
1411
+ ] }),
1412
+ /* @__PURE__ */ jsx12("div", { className: "ui-dropdown__list", role: "listbox", id: listId, "aria-label": name, style: cap, children: body }),
1413
+ /* @__PURE__ */ jsx12(
1414
+ "div",
1415
+ {
1416
+ className: "ui-dropdown__none",
1417
+ role: "status",
1418
+ "data-dd-none": "",
1419
+ "data-dd-empty": sx.empty,
1420
+ "data-dd-hint": sx.hint,
1421
+ children: none
1422
+ }
1423
+ )
1424
+ ] }) : body,
1425
+ footer
1426
+ ]
1427
+ }
1428
+ )
1429
+ ] });
1430
+ }
1431
+ function strip(o) {
1432
+ return Object.fromEntries(
1433
+ Object.entries(o).filter(([, v]) => v != null && v !== "")
1434
+ );
1435
+ }
1436
+
1437
+ // src/BackLink.tsx
1438
+ import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
1439
+ var BARE = "Back";
1440
+ var SCRIPTED = /^javascript:/i;
1441
+ var LEADING = /^[\u0000-\u0020]+/;
1442
+ var TAB_OR_NEWLINE = /[\t\n\r]/g;
1443
+ var SAID = /^back\s+to(?:\s+|$)/i;
1444
+ var text = (v) => typeof v === "string" || typeof v === "number" ? String(v).trim() : "";
1445
+ function BackLink({
1446
+ href,
1447
+ label,
1448
+ as,
1449
+ className,
1450
+ ...rest
1451
+ }) {
1452
+ const to = text(href);
1453
+ if (!to || SCRIPTED.test(to.replace(LEADING, "").replace(TAB_OR_NEWLINE, ""))) return null;
1454
+ const name = text(label).replace(SAID, "");
1455
+ const bare = !name || name.toLowerCase() === BARE.toLowerCase();
1456
+ const Tag = as ?? "a";
1457
+ return /* @__PURE__ */ jsxs11(
1458
+ Tag,
1459
+ {
1460
+ className: ["ui-back", className].filter(Boolean).join(" "),
1461
+ href: to,
1462
+ "aria-label": bare ? void 0 : `${BARE} to ${name}`,
1463
+ ...rest,
1464
+ children: [
1465
+ /* @__PURE__ */ jsx13(Icon, { name: "chevronLeft" }),
1466
+ /* @__PURE__ */ jsx13("span", { className: "ui-back__label", children: bare ? BARE : name })
1467
+ ]
1468
+ }
1469
+ );
1470
+ }
1471
+
1043
1472
  // src/index.ts
1044
1473
  var PAGE_SIZES = KIT_PAGE_SIZES;
1045
1474
  var DEFAULT_PAGE_SIZE3 = KIT_DEFAULT_PAGE_SIZE;
1046
1475
  export {
1476
+ BackLink,
1047
1477
  Badge,
1048
1478
  BusyRegion,
1049
1479
  Button,
@@ -1053,6 +1483,7 @@ export {
1053
1483
  DataTable,
1054
1484
  Denied,
1055
1485
  Drawer,
1486
+ Dropdown,
1056
1487
  Icon,
1057
1488
  Modal,
1058
1489
  PAGE_SIZES,
@@ -63,7 +63,8 @@ function ddBody({ items, sections }, listbox, sx) {
63
63
  if (sections && sections.length) {
64
64
  return sections.map((s) => {
65
65
  const head = s.label ? `<div class="ui-dropdown__group" role="presentation">${esc(s.label)}</div>` : '';
66
- const gone = sx && ddFiltering(sx.q) && !(s.items || []).some((it) => ddIsRow(it) && ddMatch(it.label, sx.q));
66
+ const gone = sx && dropdownFiltering(sx.q)
67
+ && !(s.items || []).some((it) => ddIsRow(it) && dropdownMatch(it.label, sx.q));
67
68
  return `<div class="ui-dropdown__section" role="group"${s.label ? ` aria-label="${esc(s.label)}"` : ''}${gone ? ' hidden' : ''}>${head}${(s.items || []).map(one).join('')}</div>`;
68
69
  }).join('');
69
70
  }
@@ -73,15 +74,26 @@ function ddBody({ items, sections }, listbox, sx) {
73
74
  // ---- Search --------------------------------------------------------------
74
75
  // The match is a substring of the label, anywhere in it, ignoring case and
75
76
  // accents; rows keep their order. The factory and the wiring both ask
76
- // ddMatch(), so a preset query and a typed one hide the same rows.
77
+ // dropdownMatch(), so a preset query and a typed one hide the same rows.
77
78
  // why: docs/specification.md#a-dropdown-with-a-search-field
78
79
  // NFD takes the mark off é or ö; ł, ø, đ and the rest are letters of their own
79
80
  // with nothing to take off, so they are mapped by hand.
80
81
  const FOLD = { ł: 'l', ø: 'o', đ: 'd', ð: 'd', ß: 'ss', æ: 'ae', œ: 'oe', ı: 'i', þ: 'th' };
81
82
  const fold = (s) => String(s == null ? '' : s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
82
83
  .replace(/[łøđðßæœıþ]/g, (c) => FOLD[c]);
83
- const ddFiltering = (q) => fold(q).trim() !== '';
84
- const ddMatch = (label, q) => fold(label).includes(fold(q).trim());
84
+
85
+ /**
86
+ * Is this query narrowing anything? A query of spaces, or of marks with no letter
87
+ * under them, is not — it leaves every row showing, and the separators with them.
88
+ *
89
+ * Public because a second implementation of this dropdown cannot write its own: the
90
+ * React <Dropdown> asks these two, the way <CommandPalette> asks rankGroups(), so a
91
+ * list a server rendered and the same list after a keystroke hide the same rows.
92
+ */
93
+ export const dropdownFiltering = (query) => fold(query).trim() !== '';
94
+
95
+ /** Does `label` match `query`? A substring, anywhere, ignoring case and accents. */
96
+ export const dropdownMatch = (label, query) => fold(label).includes(fold(query).trim());
85
97
  const ddIsRow = (it) => it && it !== '---' && !it.separator;
86
98
 
87
99
  let _ddSeq = 0;
@@ -100,8 +112,8 @@ function ddSearchContext(search, id) {
100
112
  }
101
113
 
102
114
  function ddRowExt(it, sx) {
103
- if (!ddIsRow(it)) return { filtering: ddFiltering(sx.q) };
104
- return { id: `${sx.base}-opt-${sx.n++}`, hidden: !ddMatch(it.label, sx.q) };
115
+ if (!ddIsRow(it)) return { filtering: dropdownFiltering(sx.q) };
116
+ return { id: `${sx.base}-opt-${sx.n++}`, hidden: !dropdownMatch(it.label, sx.q) };
105
117
  }
106
118
 
107
119
  // The no-match state. A function replacer, so a `$&` typed into the field is
@@ -118,7 +130,7 @@ function ddSearchBody({ items, sections }, sx, name, scroll) {
118
130
  const listId = `${sx.base}-list`;
119
131
  const rows = ddBody({ items, sections }, true, sx);
120
132
  const flat = (sections ? sections.flatMap((s) => s.items || []) : (items || [])).filter(ddIsRow);
121
- const shown = flat.some((it) => ddMatch(it.label, sx.q));
133
+ const shown = flat.some((it) => dropdownMatch(it.label, sx.q));
122
134
  const cap = scroll && scroll !== true ? ` style="max-height:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '';
123
135
  const input = [
124
136
  'class="ui-dropdown__search-input"', 'type="text"', 'role="combobox"',
@@ -131,7 +143,7 @@ function ddSearchBody({ items, sections }, sx, name, scroll) {
131
143
  + `<span class="ui-dropdown__search-ic" aria-hidden="true">${icon('search')}</span><input ${input}></div>`
132
144
  + `<div class="ui-dropdown__list" role="listbox" id="${esc(listId)}" aria-label="${esc(name)}"${cap}>${rows}</div>`
133
145
  + `<div class="ui-dropdown__none" role="status" data-dd-none data-dd-empty="${esc(sx.empty)}" data-dd-hint="${esc(sx.hint)}">`
134
- + `${shown || !ddFiltering(sx.q) ? '' : ddNone(sx.empty, sx.hint, sx.q)}</div>`;
146
+ + `${shown || !dropdownFiltering(sx.q) ? '' : ddNone(sx.empty, sx.hint, sx.q)}</div>`;
135
147
  }
136
148
 
137
149
  /**
@@ -144,8 +156,10 @@ function ddSearchBody({ items, sections }, sx, name, scroll) {
144
156
  * @param {string} [o.variant] 'select' (listbox) | 'menu' (inferred from items)
145
157
  * @param {Array} [o.items] [{ label, value?, description?, icon?, badge?, selected?, disabled?, href?, danger? }]
146
158
  * @param {Array} [o.sections] [{ label, items }] — grouped alternative to items
147
- * @param {string} [o.header] raw HTML pinned to the top of the panel
148
- * @param {string} [o.footer] raw HTML pinned to the bottom of the panel
159
+ * @param {string} [o.foot] content for a pinned block at the panel's bottom edge,
160
+ * drawn as `.ui-dropdown__foot` — raw HTML, so escape your own text
161
+ * @param {string} [o.header] raw HTML pinned to the top of the panel, unwrapped
162
+ * @param {string} [o.footer] raw HTML pinned to the bottom of the panel, unwrapped
149
163
  * @param {string} [o.align] 'start' (default) | 'end' — the edge the panel hugs
150
164
  * @param {string} [o.direction] 'down' (default) | 'up' | 'auto' — the way it opens
151
165
  * @param {boolean} [o.portal] mount the panel on <body>, for a clipping or sticky ancestor
@@ -158,7 +172,7 @@ function ddSearchBody({ items, sections }, sx, name, scroll) {
158
172
  */
159
173
  export function dropdown({
160
174
  label, value, placeholder = 'Select…', variant, items, sections,
161
- header = '', footer = '', triggerContent, triggerClass = '', chevron = true,
175
+ foot = '', header = '', footer = '', triggerContent, triggerClass = '', chevron = true,
162
176
  align = 'start', direction = 'down', portal = false,
163
177
  scroll = false, open = false, ariaLabel, id, panelClass = '', search = false,
164
178
  } = {}) {
@@ -202,6 +216,11 @@ export function dropdown({
202
216
  scroll && scroll !== true && !sx ? `style="max-height:${typeof scroll === 'number' ? scroll + 'px' : esc(scroll)}"` : '',
203
217
  ].filter(Boolean).join(' ');
204
218
 
219
+ // The block the sheet bleeds to the panel's bottom edge. It sits OUTSIDE the
220
+ // unwrapped `footer`, because the block that bleeds is the one that has to
221
+ // touch the edge. why: docs/specification.md#the-dropdown-panel
222
+ const footBlock = foot ? `<div class="ui-dropdown__foot">${foot}</div>` : '';
223
+
205
224
  const ddAttrs = 'data-dropdown'
206
225
  + (isSelect ? ' data-dropdown-select' : '')
207
226
  + (direction === 'auto' ? ' data-dropdown-direction="auto"' : '')
@@ -209,7 +228,9 @@ export function dropdown({
209
228
 
210
229
  return `<div class="${cx('ui-dropdown', open && 'open')}" ${ddAttrs}${id ? ` id="${esc(id)}"` : ''}>` +
211
230
  `<button ${triggerAttrs}>${trig}${chevron ? '<span class="ui-dropdown__chevron" aria-hidden="true"></span>' : ''}</button>` +
212
- `<div ${panelAttrs}>${header}${sx ? ddSearchBody({ items, sections }, sx, name, scroll) : ddBody({ items, sections }, isSelect)}${footer}</div>` +
231
+ `<div ${panelAttrs}>${header}`
232
+ + `${sx ? ddSearchBody({ items, sections }, sx, name, scroll) : ddBody({ items, sections }, isSelect)}`
233
+ + `${footer}${footBlock}</div>` +
213
234
  `</div>`;
214
235
  }
215
236
 
@@ -294,14 +315,14 @@ function ddFilter(dd) {
294
315
  const q = search.value;
295
316
  let shown = 0;
296
317
  panel.querySelectorAll('[data-dd-item]').forEach((row) => {
297
- row.hidden = !ddMatch((row.querySelector('.ui-dropdown__label') || row).textContent, q);
318
+ row.hidden = !dropdownMatch((row.querySelector('.ui-dropdown__label') || row).textContent, q);
298
319
  if (!row.hidden) shown += 1;
299
320
  });
300
- panel.querySelectorAll('.ui-dropdown__sep').forEach((el) => { el.hidden = ddFiltering(q); });
321
+ panel.querySelectorAll('.ui-dropdown__sep').forEach((el) => { el.hidden = dropdownFiltering(q); });
301
322
  panel.querySelectorAll('.ui-dropdown__section').forEach((el) => { el.hidden = !el.querySelector('[data-dd-item]:not([hidden])'); });
302
323
  const none = panel.querySelector('[data-dd-none]');
303
324
  if (none) {
304
- none.innerHTML = shown || !ddFiltering(q) ? ''
325
+ none.innerHTML = shown || !dropdownFiltering(q) ? ''
305
326
  : ddNone(none.getAttribute('data-dd-empty') || '', none.getAttribute('data-dd-hint') || '', q);
306
327
  }
307
328
  ddSetActive(dd, ddItemsOf(dd)[0] || null);