@dloizides/ui-nav 1.26.2 → 1.28.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.
package/dist/index.js CHANGED
@@ -903,7 +903,8 @@ var APP_SHELL_SUFFIX = {
903
903
  pending: "-pending",
904
904
  loading: "-loading",
905
905
  error: "-error",
906
- forbidden: "-forbidden"
906
+ forbidden: "-forbidden",
907
+ band: "-backdrop-band"
907
908
  };
908
909
  var FocusableTouchable = ({
909
910
  onPress,
@@ -1057,10 +1058,88 @@ var Topbar = ({
1057
1058
  }
1058
1059
  );
1059
1060
  };
1061
+
1062
+ // src/NavHoverFeedback.ts
1063
+ var NavHoverFeedback = /* @__PURE__ */ ((NavHoverFeedback2) => {
1064
+ NavHoverFeedback2["Default"] = "default";
1065
+ NavHoverFeedback2["TintPill"] = "tint-pill";
1066
+ NavHoverFeedback2["Underline"] = "underline";
1067
+ NavHoverFeedback2["Lift"] = "lift";
1068
+ NavHoverFeedback2["SlidingHighlight"] = "sliding-highlight";
1069
+ return NavHoverFeedback2;
1070
+ })(NavHoverFeedback || {});
1071
+
1072
+ // src/feedback/navLinkFeedback.ts
1073
+ var DEFAULT_FADE_MS = 150;
1074
+ var PRESS_SCALE = 0.97;
1075
+ var PRESS_NUDGE_PX = 1;
1076
+ var LIFT_PX = -2;
1077
+ var PILL_KINDS = /* @__PURE__ */ new Set([
1078
+ "default" /* Default */,
1079
+ "tint-pill" /* TintPill */,
1080
+ "lift" /* Lift */
1081
+ ]);
1082
+ function pillBackground(kind, isHovered, active, colors) {
1083
+ if (!PILL_KINDS.has(kind)) return void 0;
1084
+ if (active) return colors.activeBg;
1085
+ return isHovered ? colors.hoverBg : void 0;
1086
+ }
1087
+ function linkTextColor(kind, isHovered, active, colors) {
1088
+ if (active) return PILL_KINDS.has(kind) ? colors.onPrimary : colors.activeBg;
1089
+ return isHovered ? colors.hoverText : colors.rest;
1090
+ }
1091
+ function pressTransform(kind, pressed, isHovered) {
1092
+ if (kind === "tint-pill" /* TintPill */) return pressed ? [{ scale: PRESS_SCALE }] : void 0;
1093
+ if (kind === "underline" /* Underline */) return pressed ? [{ translateY: PRESS_NUDGE_PX }] : void 0;
1094
+ if (kind !== "lift" /* Lift */) return void 0;
1095
+ if (pressed) return [{ translateY: 0 }];
1096
+ return isHovered ? [{ translateY: LIFT_PX }] : void 0;
1097
+ }
1098
+ function feedbackDuration(kind) {
1099
+ if (kind === "default" /* Default */) return DEFAULT_FADE_MS;
1100
+ return kind === "tint-pill" /* TintPill */ ? uiMotion.motionDurations.fast : uiMotion.motionDurations.base;
1101
+ }
1102
+ function resolveLinkFeedback(kind, state, colors, reducedMotion) {
1103
+ const isHovered = state.hovered && !state.active;
1104
+ const underlined = state.active || isHovered;
1105
+ return {
1106
+ background: pillBackground(kind, isHovered, state.active, colors),
1107
+ textColor: linkTextColor(kind, isHovered, state.active, colors),
1108
+ transform: reducedMotion ? void 0 : pressTransform(kind, state.pressed, isHovered),
1109
+ shadow: kind === "lift" /* Lift */ && isHovered,
1110
+ underline: kind === "underline" /* Underline */ ? { color: colors.activeBg, scale: underlined ? 1 : 0 } : null,
1111
+ durationMs: reducedMotion ? uiMotion.motionDurations.instant : feedbackDuration(kind)
1112
+ };
1113
+ }
1060
1114
  var TEXT_ON_PRIMARY2 = "#ffffff";
1061
1115
  function ariaCurrentProps2(isActive) {
1062
1116
  return isActive ? { "aria-current": "page" } : {};
1063
1117
  }
1118
+ var FEEDBACK_PROPERTIES = "color, background-color, transform, box-shadow";
1119
+ var UNDERLINE_INSET = 14;
1120
+ var UNDERLINE_HEIGHT = 2;
1121
+ var SHADOW_OPACITY = 0.18;
1122
+ var SHADOW_RADIUS = 6;
1123
+ var SHADOW_OFFSET = { width: 0, height: 2 };
1124
+ function feedbackStyle(kind, feedback, shadowColor, reducedMotion) {
1125
+ const background = feedback.background === void 0 ? {} : { backgroundColor: feedback.background };
1126
+ const shadow = feedback.shadow ? { shadowColor, shadowOpacity: SHADOW_OPACITY, shadowRadius: SHADOW_RADIUS, shadowOffset: SHADOW_OFFSET } : {};
1127
+ const transition = kind === "default" /* Default */ ? hoverTransitionStyle(reducedMotion) : webTransition(FEEDBACK_PROPERTIES, feedback.durationMs, reducedMotion);
1128
+ return [background, shadow, { transform: feedback.transform }, transition ?? {}];
1129
+ }
1130
+ function underlineStyle(feedback, reducedMotion) {
1131
+ if (feedback.underline === null) return null;
1132
+ const line = {
1133
+ position: "absolute",
1134
+ left: UNDERLINE_INSET,
1135
+ right: UNDERLINE_INSET,
1136
+ bottom: 0,
1137
+ height: UNDERLINE_HEIGHT,
1138
+ backgroundColor: feedback.underline.color,
1139
+ transform: [{ scaleX: feedback.underline.scale }]
1140
+ };
1141
+ return [line, webTransition("transform", feedback.durationMs, reducedMotion) ?? {}];
1142
+ }
1064
1143
  var NavBarLink = ({
1065
1144
  item,
1066
1145
  isActive,
@@ -1069,13 +1148,20 @@ var NavBarLink = ({
1069
1148
  reducedMotion,
1070
1149
  colors,
1071
1150
  navigateHint,
1072
- onPress
1151
+ onPress,
1152
+ hoverFeedback = "default" /* Default */,
1153
+ onHoverChange
1073
1154
  }) => {
1074
1155
  const [hovered, setHovered] = React.useState(false);
1075
1156
  const [focused, setFocused] = React.useState(false);
1076
- const isHovered = hovered && !isActive;
1077
- const textColor = isActive ? TEXT_ON_PRIMARY2 : isHovered ? colors.hoverText : colors.rest;
1078
- const pillStyle = isActive ? { backgroundColor: colors.activeBg } : isHovered ? { backgroundColor: colors.hoverBg } : void 0;
1157
+ const linkColors = { ...colors, onPrimary: TEXT_ON_PRIMARY2 };
1158
+ const resolve = (pressed) => resolveLinkFeedback(hoverFeedback, { hovered, pressed, active: isActive }, linkColors, reducedMotion);
1159
+ const textColor = resolve(false).textColor;
1160
+ const underline = underlineStyle(resolve(false), reducedMotion);
1161
+ const setHover = (next) => {
1162
+ setHovered(next);
1163
+ onHoverChange?.(item.key, next);
1164
+ };
1079
1165
  return /* @__PURE__ */ jsxRuntime.jsxs(
1080
1166
  reactNative.Pressable,
1081
1167
  {
@@ -1083,27 +1169,61 @@ var NavBarLink = ({
1083
1169
  accessibilityLabel: item.label,
1084
1170
  accessibilityRole: "link",
1085
1171
  accessibilityState: { selected: isActive },
1086
- style: [
1172
+ style: ({ pressed }) => [
1087
1173
  navBarStyles.link,
1088
1174
  collapsed ? navBarStyles.linkStacked : void 0,
1089
- pillStyle,
1090
- hoverTransitionStyle(reducedMotion),
1175
+ ...feedbackStyle(hoverFeedback, resolve(pressed), colors.rest, reducedMotion),
1091
1176
  focusRingStyle(focused, colors.ring)
1092
1177
  ],
1093
1178
  testID: item.testID ?? item.key,
1094
1179
  onBlur: () => setFocused(false),
1095
1180
  onFocus: () => setFocused(true),
1096
- onHoverIn: () => setHovered(true),
1097
- onHoverOut: () => setHovered(false),
1181
+ onHoverIn: () => setHover(true),
1182
+ onHoverOut: () => setHover(false),
1098
1183
  onPress: () => onPress(item.route),
1099
1184
  ...ariaCurrentProps2(isActive),
1100
1185
  children: [
1101
1186
  typeof item.renderIcon === "function" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: navBarStyles.linkIcon, children: item.renderIcon(textColor, iconSize) }) : null,
1187
+ underline === null ? null : /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { pointerEvents: "none", style: underline }),
1102
1188
  /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [navBarStyles.linkText, { color: textColor }], children: item.label })
1103
1189
  ]
1104
1190
  }
1105
1191
  );
1106
1192
  };
1193
+ var HIGHLIGHT_RADIUS = 8;
1194
+ var SLIDE_PROPERTIES = "left, top, width, height";
1195
+ function slideHighlightRect(layouts, activeKey, hoveredKey) {
1196
+ const hoveredRect = hoveredKey === null ? void 0 : layouts[hoveredKey];
1197
+ const activeRect = activeKey === void 0 ? void 0 : layouts[activeKey];
1198
+ return hoveredRect ?? activeRect ?? null;
1199
+ }
1200
+ function useSlidingHighlight(enabled, activeKey, color, reducedMotion) {
1201
+ const [layouts, setLayouts] = React.useState({});
1202
+ const [hoveredKey, setHoveredKey] = React.useState(null);
1203
+ const measure = React.useCallback(
1204
+ (key, layout) => {
1205
+ if (enabled) setLayouts((prev) => ({ ...prev, [key]: layout }));
1206
+ },
1207
+ [enabled]
1208
+ );
1209
+ const hover = React.useCallback((key, hovered) => {
1210
+ setHoveredKey((prev) => hovered ? key : prev === key ? null : prev);
1211
+ }, []);
1212
+ const rect = enabled ? slideHighlightRect(layouts, activeKey, hoveredKey) : null;
1213
+ const style = rect === null ? null : [
1214
+ {
1215
+ position: "absolute",
1216
+ left: rect.x,
1217
+ top: rect.y,
1218
+ width: rect.width,
1219
+ height: rect.height,
1220
+ borderRadius: HIGHLIGHT_RADIUS,
1221
+ backgroundColor: color
1222
+ },
1223
+ webTransition(SLIDE_PROPERTIES, uiMotion.motionDurations.base, reducedMotion)
1224
+ ];
1225
+ return { style, measure, hover };
1226
+ }
1107
1227
  var TEXT_ON_PRIMARY3 = "#ffffff";
1108
1228
  var NO_ACTIVE_ROUTE = "";
1109
1229
  var OVERFLOW_MENU_MIN_WIDTH = 200;
@@ -1244,7 +1364,8 @@ var NavBarInner = ({
1244
1364
  overflowLabel = "More",
1245
1365
  overflowHint = "",
1246
1366
  containerStyle,
1247
- contentMaxWidth
1367
+ contentMaxWidth,
1368
+ hoverFeedback = "default" /* Default */
1248
1369
  }) => {
1249
1370
  const { theme } = uiFeedback.useUi();
1250
1371
  const colors = theme.colors;
@@ -1278,6 +1399,12 @@ var NavBarInner = ({
1278
1399
  [onNavigate]
1279
1400
  );
1280
1401
  const activeRoute = resolveActiveRoute(items, pathname);
1402
+ const slide = useSlidingHighlight(
1403
+ hoverFeedback === "sliding-highlight" /* SlidingHighlight */,
1404
+ items.find((item) => item.route === activeRoute)?.key,
1405
+ colors.surfaceElevated,
1406
+ reducedMotion
1407
+ );
1281
1408
  const linkColors = React.useMemo(
1282
1409
  () => ({
1283
1410
  rest: colors.textSecondary,
@@ -1327,9 +1454,11 @@ var NavBarInner = ({
1327
1454
  colors: linkColors,
1328
1455
  iconSize: NAV_ICON_SIZE,
1329
1456
  isActive: item.route === activeRoute,
1457
+ hoverFeedback,
1330
1458
  item,
1331
1459
  navigateHint,
1332
1460
  reducedMotion,
1461
+ onHoverChange: slide.hover,
1333
1462
  onPress: handlePress
1334
1463
  }
1335
1464
  );
@@ -1355,11 +1484,15 @@ var NavBarInner = ({
1355
1484
  testID: NAV_TEST_IDS.navBarLinks,
1356
1485
  onLayout: (event) => setAvailableWidth(event.nativeEvent.layout.width),
1357
1486
  children: [
1487
+ slide.style === null ? null : /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { pointerEvents: "none", style: slide.style }),
1358
1488
  visibleItems.map((item, index) => /* @__PURE__ */ jsxRuntime.jsx(
1359
1489
  reactNative.View,
1360
1490
  {
1361
1491
  style: navBarStyles.linkCell,
1362
- onLayout: (event) => setItemWidth(index, event.nativeEvent.layout.width),
1492
+ onLayout: (event) => {
1493
+ setItemWidth(index, event.nativeEvent.layout.width);
1494
+ slide.measure(item.key, event.nativeEvent.layout);
1495
+ },
1363
1496
  children: renderLink(item)
1364
1497
  },
1365
1498
  item.key
@@ -1410,13 +1543,53 @@ var Nav = (props) => {
1410
1543
  }
1411
1544
  return /* @__PURE__ */ jsxRuntime.jsx(NavBar, { ...props });
1412
1545
  };
1546
+ var RAIL_FULL_BREAKPOINT = uiLayout.LAYOUT_COLLAPSE_BREAKPOINT;
1547
+ var DEFAULT_COLLAPSED_RAIL_MAX = 1024;
1548
+ function resolveRailMode({ viewport, hasSidebar, hasCollapsed, range }) {
1549
+ if (!hasSidebar) return "none";
1550
+ if (hasCollapsed) {
1551
+ if (viewport >= range.max) return "full";
1552
+ if (viewport >= range.min) return "collapsed";
1553
+ return "drawer";
1554
+ }
1555
+ return viewport >= RAIL_FULL_BREAKPOINT ? "full" : "drawer";
1556
+ }
1557
+ var styles3 = reactNative.StyleSheet.create({
1558
+ band: { left: 0, position: "absolute", right: 0, top: 0 }
1559
+ });
1560
+ function readBackdrop(theme) {
1561
+ return theme.backdrop;
1562
+ }
1563
+ function rootStyle(backdrop, fallback) {
1564
+ if (backdrop === void 0) return { backgroundColor: fallback };
1565
+ const base = { backgroundColor: backdrop.color };
1566
+ if (!IS_WEB || backdrop.image === null) return base;
1567
+ const web = { backgroundImage: backdrop.image, backgroundSize: backdrop.imageSize ?? "auto" };
1568
+ return { ...base, ...web };
1569
+ }
1570
+ function resolveShellBackdrop(theme, viewport, contentPadding, testID) {
1571
+ const backdrop = readBackdrop(theme);
1572
+ const band = backdrop?.band ?? null;
1573
+ const root = rootStyle(backdrop, theme.colors.background);
1574
+ if (band === null) return { root, chrome: void 0, chromeText: theme.colors.text, band: null };
1575
+ const overlap = viewport < RAIL_FULL_BREAKPOINT ? band.overlap.compact : band.overlap.wide;
1576
+ const element = /* @__PURE__ */ jsxRuntime.jsx(
1577
+ reactNative.View,
1578
+ {
1579
+ pointerEvents: "none",
1580
+ style: [styles3.band, { backgroundColor: band.color, height: contentPadding + overlap }],
1581
+ testID: `${testID}${APP_SHELL_SUFFIX.band}`
1582
+ }
1583
+ );
1584
+ return { root, chrome: { backgroundColor: band.color }, chromeText: band.text, band: element };
1585
+ }
1413
1586
  var CARD_PADDING = 20;
1414
1587
  var CARD_BORDER_RADIUS = 12;
1415
1588
  var CARD_BORDER_WIDTH = 1;
1416
1589
  var CARD_TITLE_FONT_SIZE = 16;
1417
1590
  var CARD_MESSAGE_FONT_SIZE = 14;
1418
1591
  var CARD_TITLE_MARGIN_BOTTOM = 8;
1419
- var styles3 = reactNative.StyleSheet.create({
1592
+ var styles4 = reactNative.StyleSheet.create({
1420
1593
  // Loading is a TRANSITIONAL state: the spinner is an absolute overlay ON TOP of
1421
1594
  // stably-mounted children, never a replacement for them. Replacing children here
1422
1595
  // would unmount whatever they host (e.g. a navigator), and on remount React
@@ -1448,9 +1621,9 @@ function MessageCard({
1448
1621
  }) {
1449
1622
  const { theme } = uiFeedback.useUi();
1450
1623
  const colors = theme.colors;
1451
- return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles3.card, { backgroundColor: colors.surface, borderColor: accentColor }], testID, children: [
1452
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles3.cardTitle, { color: accentColor }], children: message.titleText }),
1453
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles3.cardMessage, { color: colors.textSecondary }], children: message.messageText })
1624
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles4.card, { backgroundColor: colors.surface, borderColor: accentColor }], testID, children: [
1625
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles4.cardTitle, { color: accentColor }], children: message.titleText }),
1626
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles4.cardMessage, { color: colors.textSecondary }], children: message.messageText })
1454
1627
  ] });
1455
1628
  }
1456
1629
  function useContentBody(state, children, testID) {
@@ -1462,12 +1635,12 @@ function useContentBody(state, children, testID) {
1462
1635
  return /* @__PURE__ */ jsxRuntime.jsx(MessageCard, { accentColor: errorColor, message: state.forbidden, testID: `${testID}${APP_SHELL_SUFFIX.forbidden}` });
1463
1636
  if (state.error)
1464
1637
  return /* @__PURE__ */ jsxRuntime.jsx(MessageCard, { accentColor: errorColor, message: state.error, testID: `${testID}${APP_SHELL_SUFFIX.error}` });
1465
- return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles3.loadingHost, children: [
1638
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles4.loadingHost, children: [
1466
1639
  children,
1467
1640
  state.loading === true ? /* @__PURE__ */ jsxRuntime.jsx(
1468
1641
  reactNative.View,
1469
1642
  {
1470
- style: [styles3.loadingOverlay, { backgroundColor: theme.colors.background }],
1643
+ style: [styles4.loadingOverlay, { backgroundColor: theme.colors.background }],
1471
1644
  testID: `${testID}${APP_SHELL_SUFFIX.loading}`,
1472
1645
  children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.ActivityIndicator, { color: primary, size: "large" })
1473
1646
  }
@@ -1494,7 +1667,7 @@ var DEFAULT_DRAWER_LABELS = {
1494
1667
  closeLabel: "Close menu",
1495
1668
  closeHint: "Close the navigation menu"
1496
1669
  };
1497
- var styles4 = reactNative.StyleSheet.create({
1670
+ var styles5 = reactNative.StyleSheet.create({
1498
1671
  overlay: { ...reactNative.StyleSheet.absoluteFillObject, zIndex: DRAWER_Z_INDEX },
1499
1672
  // The scrim's tap-to-close hit area is anchored to the RIGHT of the panel
1500
1673
  // (`left: DRAWER_WIDTH`), so it NEVER overlaps the panel: nav-item taps always
@@ -1529,10 +1702,10 @@ var MenuToggle = ({ label, hint, onOpen, testID }) => {
1529
1702
  accessibilityHint: hint,
1530
1703
  accessibilityLabel: label,
1531
1704
  ringColor: theme.palette.primary["500"],
1532
- style: styles4.menuToggle,
1705
+ style: styles5.menuToggle,
1533
1706
  testID,
1534
1707
  onPress: onOpen,
1535
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles4.menuToggleGlyph, { color: theme.colors.text }], children: MENU_GLYPH2 })
1708
+ children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles5.menuToggleGlyph, { color: theme.colors.text }], children: MENU_GLYPH2 })
1536
1709
  }
1537
1710
  );
1538
1711
  };
@@ -1558,14 +1731,14 @@ var MobileDrawer = ({
1558
1731
  node.addEventListener("click", handleClick);
1559
1732
  return () => node.removeEventListener("click", handleClick);
1560
1733
  }, [onClose]);
1561
- return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles4.overlay, children: [
1734
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles5.overlay, children: [
1562
1735
  /* @__PURE__ */ jsxRuntime.jsx(
1563
1736
  reactNative.Pressable,
1564
1737
  {
1565
1738
  accessibilityHint: labels.closeHint,
1566
1739
  accessibilityLabel: labels.closeLabel,
1567
1740
  accessibilityRole: "button",
1568
- style: styles4.scrim,
1741
+ style: styles5.scrim,
1569
1742
  testID: scrimTestID,
1570
1743
  onPress: onClose
1571
1744
  }
@@ -1576,24 +1749,13 @@ var MobileDrawer = ({
1576
1749
  ref: panelRef,
1577
1750
  "aria-modal": true,
1578
1751
  role: "dialog",
1579
- style: [styles4.panel, { backgroundColor: theme.colors.surface }],
1752
+ style: [styles5.panel, { backgroundColor: theme.colors.surface }],
1580
1753
  testID: drawerTestID,
1581
1754
  children: renderTextSlot(sidebar, { color: theme.colors.text })
1582
1755
  }
1583
1756
  )
1584
1757
  ] });
1585
1758
  };
1586
- var RAIL_FULL_BREAKPOINT = uiLayout.LAYOUT_COLLAPSE_BREAKPOINT;
1587
- var DEFAULT_COLLAPSED_RAIL_MAX = 1024;
1588
- function resolveRailMode({ viewport, hasSidebar, hasCollapsed, range }) {
1589
- if (!hasSidebar) return "none";
1590
- if (hasCollapsed) {
1591
- if (viewport >= range.max) return "full";
1592
- if (viewport >= range.min) return "collapsed";
1593
- return "drawer";
1594
- }
1595
- return viewport >= RAIL_FULL_BREAKPOINT ? "full" : "drawer";
1596
- }
1597
1759
  function resolveContentMaxWidth(width, viewport) {
1598
1760
  if (width === "full") return "full";
1599
1761
  const wideMinViewport = width.wideMinViewport ?? Number.POSITIVE_INFINITY;
@@ -1605,7 +1767,7 @@ function useContentMaxWidth(width) {
1605
1767
  return resolveContentMaxWidth(width, viewport);
1606
1768
  }
1607
1769
  var DEFAULT_CONTENT_PADDING = 24;
1608
- var styles5 = reactNative.StyleSheet.create({
1770
+ var styles6 = reactNative.StyleSheet.create({
1609
1771
  root: { flex: 1 },
1610
1772
  // Auth-pending spinner: an opaque absolute overlay over the mounted shell, so
1611
1773
  // `children` (and any navigator they host) are never unmounted while the gate
@@ -1645,6 +1807,7 @@ var AppShell = ({
1645
1807
  const maxWidth = useContentMaxWidth(width);
1646
1808
  const body = useContentBody(state, children, testID);
1647
1809
  const { width: viewportWidth } = reactNative.useWindowDimensions();
1810
+ const look = resolveShellBackdrop(theme, viewportWidth, contentPadding, testID);
1648
1811
  const railRange = collapsedRailRange ?? { min: RAIL_FULL_BREAKPOINT, max: DEFAULT_COLLAPSED_RAIL_MAX };
1649
1812
  const railMode = resolveRailMode({
1650
1813
  viewport: viewportWidth,
@@ -1666,18 +1829,21 @@ var AppShell = ({
1666
1829
  }, [isUnauthenticated, gate]);
1667
1830
  const gatePending = gate?.pending === true;
1668
1831
  if (isUnauthenticated) return null;
1669
- const columnStyle = maxWidth === "full" ? styles5.columnFull : [styles5.columnCapped, { maxWidth }];
1670
- const navInnerStyle = chromeAlignment === "content" && maxWidth !== "full" ? [styles5.chromeCapped, { maxWidth, paddingHorizontal: contentPadding }] : void 0;
1671
- const scroller = /* @__PURE__ */ jsxRuntime.jsx(
1832
+ const columnStyle = maxWidth === "full" ? styles6.columnFull : [styles6.columnCapped, { maxWidth }];
1833
+ const navInnerStyle = chromeAlignment === "content" && maxWidth !== "full" ? [styles6.chromeCapped, { maxWidth, paddingHorizontal: contentPadding }] : void 0;
1834
+ const scroller = /* @__PURE__ */ jsxRuntime.jsxs(
1672
1835
  reactNative.ScrollView,
1673
1836
  {
1674
- contentContainerStyle: [styles5.scrollContent, { padding: contentPadding }],
1675
- style: styles5.scroll,
1837
+ contentContainerStyle: [styles6.scrollContent, { padding: contentPadding }],
1838
+ style: styles6.scroll,
1676
1839
  testID: `${testID}${APP_SHELL_SUFFIX.content}`,
1677
- children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: columnStyle, children: body })
1840
+ children: [
1841
+ look.band,
1842
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: columnStyle, children: body })
1843
+ ]
1678
1844
  }
1679
1845
  );
1680
- const headerRegion = useDrawer ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles5.headerRow, testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: [
1846
+ const headerRegion = useDrawer ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles6.headerRow, look.chrome], testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: [
1681
1847
  /* @__PURE__ */ jsxRuntime.jsx(
1682
1848
  MenuToggle,
1683
1849
  {
@@ -1687,14 +1853,14 @@ var AppShell = ({
1687
1853
  onOpen: openDrawer
1688
1854
  }
1689
1855
  ),
1690
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles5.headerFill, children: renderTextSlot(header, { color: theme.colors.text }) })
1691
- ] }) : /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: renderTextSlot(header, { color: theme.colors.text }) });
1856
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: styles6.headerFill, children: renderTextSlot(header, { color: look.chromeText }) })
1857
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: look.chrome, testID: `${testID}${APP_SHELL_SUFFIX.header}`, children: renderTextSlot(header, { color: look.chromeText }) });
1692
1858
  const railRegion = railMode === "full" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.sidebar}`, children: renderTextSlot(sidebar, { color: theme.colors.text }) }) : railMode === "collapsed" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.collapsedSidebar}`, children: renderTextSlot(collapsedSidebar, { color: theme.colors.text }) }) : null;
1693
- return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles5.root, { backgroundColor: theme.colors.background }], testID, children: [
1859
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [styles6.root, look.root], testID, children: [
1694
1860
  headerRegion,
1695
- nav !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.nav}`, children: navInnerStyle !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: navInnerStyle, children: renderTextSlot(nav, { color: theme.colors.text }) }) : renderTextSlot(nav, { color: theme.colors.text }) }) : null,
1861
+ nav !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: look.chrome, testID: `${testID}${APP_SHELL_SUFFIX.nav}`, children: navInnerStyle !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: navInnerStyle, children: renderTextSlot(nav, { color: look.chromeText }) }) : renderTextSlot(nav, { color: look.chromeText }) }) : null,
1696
1862
  banner !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID: `${testID}${APP_SHELL_SUFFIX.banner}`, children: renderTextSlot(banner, { color: theme.colors.text }) }) : null,
1697
- railRegion !== null ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles5.bodyRow, children: [
1863
+ railRegion !== null ? /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles6.bodyRow, children: [
1698
1864
  railRegion,
1699
1865
  scroller
1700
1866
  ] }) : scroller,
@@ -1711,7 +1877,7 @@ var AppShell = ({
1711
1877
  gatePending ? /* @__PURE__ */ jsxRuntime.jsx(
1712
1878
  reactNative.View,
1713
1879
  {
1714
- style: [styles5.pendingOverlay, { backgroundColor: theme.colors.background }],
1880
+ style: [styles6.pendingOverlay, { backgroundColor: theme.colors.background }],
1715
1881
  testID: `${testID}${APP_SHELL_SUFFIX.pending}`,
1716
1882
  children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.ActivityIndicator, { color: primary, size: "large" })
1717
1883
  }
@@ -2078,7 +2244,7 @@ var KEY_DOWN = "ArrowDown";
2078
2244
  var KEY_UP = "ArrowUp";
2079
2245
  var KEY_ENTER = "Enter";
2080
2246
  var KEY_ESCAPE = "Escape";
2081
- var styles6 = reactNative.StyleSheet.create({
2247
+ var styles7 = reactNative.StyleSheet.create({
2082
2248
  overlay: { ...reactNative.StyleSheet.absoluteFillObject, zIndex: OVERLAY_Z_INDEX, alignItems: "center" },
2083
2249
  scrim: { ...reactNative.StyleSheet.absoluteFillObject, backgroundColor: SCRIM_COLOR2 },
2084
2250
  panel: {
@@ -2107,14 +2273,14 @@ var PaletteRow = ({ item, selected, onChoose }) => {
2107
2273
  accessibilityLabel: item.label,
2108
2274
  accessibilityRole: "button",
2109
2275
  accessibilityState: { selected },
2110
- style: [styles6.row, selected ? { backgroundColor: colors.border } : void 0],
2276
+ style: [styles7.row, selected ? { backgroundColor: colors.border } : void 0],
2111
2277
  testID: item.testID ?? item.key,
2112
2278
  onPress: () => onChoose(item),
2113
2279
  children: [
2114
2280
  typeof item.renderIcon === "function" ? item.renderIcon(colors.textSecondary, NAV_ICON_SIZE) : null,
2115
- /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles6.rowText, children: [
2116
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles6.rowLabel, { color: colors.text }], children: item.label }),
2117
- item.hint ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles6.rowHint, { color: colors.textSecondary }], children: item.hint }) : null
2281
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles7.rowText, children: [
2282
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles7.rowLabel, { color: colors.text }], children: item.label }),
2283
+ item.hint ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles7.rowHint, { color: colors.textSecondary }], children: item.hint }) : null
2118
2284
  ] })
2119
2285
  ]
2120
2286
  }
@@ -2154,14 +2320,14 @@ var CommandPalette = ({ open, items, onClose, labels, testID }) => {
2154
2320
  [results, selected, choose, onClose]
2155
2321
  );
2156
2322
  if (!open) return null;
2157
- return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles6.overlay, children: [
2323
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles7.overlay, children: [
2158
2324
  /* @__PURE__ */ jsxRuntime.jsx(
2159
2325
  reactNative.Pressable,
2160
2326
  {
2161
2327
  accessibilityHint: labels.closeHint,
2162
2328
  accessibilityLabel: labels.closeLabel,
2163
2329
  accessibilityRole: "button",
2164
- style: styles6.scrim,
2330
+ style: styles7.scrim,
2165
2331
  testID: `${testID ?? "command-palette"}-scrim`,
2166
2332
  onPress: onClose
2167
2333
  }
@@ -2172,7 +2338,7 @@ var CommandPalette = ({ open, items, onClose, labels, testID }) => {
2172
2338
  "aria-modal": true,
2173
2339
  "aria-label": labels.regionLabel,
2174
2340
  role: "dialog",
2175
- style: [styles6.panel, { backgroundColor: colors.surface, borderColor: colors.border }],
2341
+ style: [styles7.panel, { backgroundColor: colors.surface, borderColor: colors.border }],
2176
2342
  testID: testID ?? "command-palette",
2177
2343
  children: [
2178
2344
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -2182,14 +2348,14 @@ var CommandPalette = ({ open, items, onClose, labels, testID }) => {
2182
2348
  accessibilityLabel: labels.placeholder,
2183
2349
  placeholder: labels.placeholder,
2184
2350
  placeholderTextColor: colors.textSecondary,
2185
- style: [styles6.input, { color: colors.text, borderBottomColor: colors.border }],
2351
+ style: [styles7.input, { color: colors.text, borderBottomColor: colors.border }],
2186
2352
  testID: `${testID ?? "command-palette"}-input`,
2187
2353
  value: query,
2188
2354
  onChangeText: setQuery,
2189
2355
  onKeyPress
2190
2356
  }
2191
2357
  ),
2192
- results.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles6.empty, { color: colors.textSecondary }], children: labels.emptyText }) : /* @__PURE__ */ jsxRuntime.jsx(reactNative.ScrollView, { keyboardShouldPersistTaps: "handled", style: styles6.list, children: results.map((item, index) => /* @__PURE__ */ jsxRuntime.jsx(PaletteRow, { item, selected: index === selected, onChoose: choose }, item.key)) })
2358
+ results.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles7.empty, { color: colors.textSecondary }], children: labels.emptyText }) : /* @__PURE__ */ jsxRuntime.jsx(reactNative.ScrollView, { keyboardShouldPersistTaps: "handled", style: styles7.list, children: results.map((item, index) => /* @__PURE__ */ jsxRuntime.jsx(PaletteRow, { item, selected: index === selected, onChoose: choose }, item.key)) })
2193
2359
  ]
2194
2360
  }
2195
2361
  )
@@ -2220,11 +2386,202 @@ function accessibleNavItems(user, table, translate) {
2220
2386
  return roleRoutesToNavItems(authWeb.resolveAccessibleRoutes(user, table), translate);
2221
2387
  }
2222
2388
 
2389
+ // src/TabFeedback.ts
2390
+ var TabFeedback = /* @__PURE__ */ ((TabFeedback2) => {
2391
+ TabFeedback2["Default"] = "default";
2392
+ TabFeedback2["IndicatorPill"] = "indicator-pill";
2393
+ TabFeedback2["DimSpring"] = "dim-spring";
2394
+ TabFeedback2["SlidingBar"] = "sliding-bar";
2395
+ TabFeedback2["IconLift"] = "icon-lift";
2396
+ return TabFeedback2;
2397
+ })(TabFeedback || {});
2398
+
2399
+ // src/feedback/tabPressFeedback.ts
2400
+ var FULL = 1;
2401
+ var PERCENT = 100;
2402
+ var PRESS_ICON_SCALE = 0.92;
2403
+ var DIM_OPACITY = 0.6;
2404
+ var ICON_LIFT_PX = -3;
2405
+ var SPRING_OVERSHOOT = 1.06;
2406
+ function tabIconTransform(kind, state) {
2407
+ if (kind === "indicator-pill" /* IndicatorPill */ && state.pressed) return [{ scale: PRESS_ICON_SCALE }];
2408
+ const lifted = state.active || state.pressed;
2409
+ return kind === "icon-lift" /* IconLift */ && lifted ? [{ translateY: ICON_LIFT_PX }] : void 0;
2410
+ }
2411
+ function tabDuration(kind) {
2412
+ if (kind === "default" /* Default */) return uiMotion.motionDurations.instant;
2413
+ const slow = kind === "dim-spring" /* DimSpring */ || kind === "sliding-bar" /* SlidingBar */;
2414
+ return slow ? uiMotion.motionDurations.slow : uiMotion.motionDurations.base;
2415
+ }
2416
+ function resolveTabFeedback(kind, state, reducedMotion) {
2417
+ const pillScale = state.active ? FULL : 0;
2418
+ return {
2419
+ opacity: kind === "dim-spring" /* DimSpring */ && state.pressed ? DIM_OPACITY : FULL,
2420
+ iconTransform: reducedMotion ? void 0 : tabIconTransform(kind, state),
2421
+ pillScale: kind === "indicator-pill" /* IndicatorPill */ ? pillScale : null,
2422
+ spring: kind === "dim-spring" /* DimSpring */ && !reducedMotion,
2423
+ durationMs: reducedMotion ? uiMotion.motionDurations.instant : tabDuration(kind)
2424
+ };
2425
+ }
2426
+ function slidingBarPosition(activeIndex, count) {
2427
+ if (activeIndex < 0 || count <= 0) return null;
2428
+ const share = PERCENT / count;
2429
+ return { left: `${activeIndex * share}%`, width: `${share}%` };
2430
+ }
2431
+ function springSteps() {
2432
+ return [
2433
+ { toValue: SPRING_OVERSHOOT, durationMs: uiMotion.motionDurations.fast },
2434
+ { toValue: FULL, durationMs: uiMotion.motionDurations.slow - uiMotion.motionDurations.fast }
2435
+ ];
2436
+ }
2437
+ function useSettleSpring(active, enabled) {
2438
+ const scale = React.useRef(new reactNative.Animated.Value(FULL)).current;
2439
+ React.useEffect(() => {
2440
+ if (!enabled || !active) return void 0;
2441
+ const steps = springSteps().map(
2442
+ (step) => reactNative.Animated.timing(scale, {
2443
+ toValue: step.toValue,
2444
+ duration: step.durationMs,
2445
+ easing: uiMotion.motionEasings.standard,
2446
+ useNativeDriver: false
2447
+ })
2448
+ );
2449
+ const animation = reactNative.Animated.sequence(steps);
2450
+ animation.start();
2451
+ return () => animation.stop();
2452
+ }, [active, enabled, scale]);
2453
+ return scale;
2454
+ }
2455
+ var ICON_SIZE = 24;
2456
+ var PILL_WIDTH = 56;
2457
+ var PILL_HEIGHT = 32;
2458
+ var PILL_RADIUS = 16;
2459
+ var TAB_MIN_HEIGHT = 56;
2460
+ var LABEL_SIZE = 12;
2461
+ var LABEL_GAP = 2;
2462
+ var TAB_PADDING = 4;
2463
+ var styles8 = reactNative.StyleSheet.create({
2464
+ tab: {
2465
+ flex: 1,
2466
+ minHeight: TAB_MIN_HEIGHT,
2467
+ alignItems: "center",
2468
+ justifyContent: "center",
2469
+ paddingVertical: TAB_PADDING,
2470
+ rowGap: LABEL_GAP
2471
+ },
2472
+ iconWrap: { width: PILL_WIDTH, height: PILL_HEIGHT, alignItems: "center", justifyContent: "center" },
2473
+ pill: { ...reactNative.StyleSheet.absoluteFillObject, borderRadius: PILL_RADIUS },
2474
+ label: { fontSize: LABEL_SIZE, textAlign: "center" }
2475
+ });
2476
+ function ariaCurrentProps3(isActive) {
2477
+ return isActive ? { "aria-current": "page" } : {};
2478
+ }
2479
+ var BottomTab = ({
2480
+ item,
2481
+ isActive,
2482
+ kind,
2483
+ colors,
2484
+ reducedMotion,
2485
+ navigateHint,
2486
+ onPress
2487
+ }) => {
2488
+ const [pressed, setPressed] = React.useState(false);
2489
+ const feedback = resolveTabFeedback(kind, { active: isActive, pressed }, reducedMotion);
2490
+ const scale = useSettleSpring(isActive, feedback.spring);
2491
+ const color = isActive ? colors.active : colors.rest;
2492
+ const motion = webTransition("transform", feedback.durationMs, reducedMotion);
2493
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2494
+ reactNative.Pressable,
2495
+ {
2496
+ accessibilityHint: navigateHint(item.label),
2497
+ accessibilityLabel: item.label,
2498
+ accessibilityRole: "link",
2499
+ accessibilityState: { selected: isActive },
2500
+ style: [styles8.tab, { opacity: feedback.opacity }],
2501
+ testID: item.testID ?? item.key,
2502
+ onPress: () => onPress(item.route),
2503
+ onPressIn: () => setPressed(true),
2504
+ onPressOut: () => setPressed(false),
2505
+ ...ariaCurrentProps3(isActive),
2506
+ children: [
2507
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.Animated.View, { style: [styles8.iconWrap, { transform: [{ scale }] }], children: [
2508
+ feedback.pillScale === null ? null : /* @__PURE__ */ jsxRuntime.jsx(
2509
+ reactNative.View,
2510
+ {
2511
+ pointerEvents: "none",
2512
+ style: [styles8.pill, { backgroundColor: colors.pill, transform: [{ scale: feedback.pillScale }] }, motion]
2513
+ }
2514
+ ),
2515
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [{ transform: feedback.iconTransform }, motion], children: item.renderIcon?.(color, ICON_SIZE) })
2516
+ ] }),
2517
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles8.label, { color }], children: item.label })
2518
+ ]
2519
+ }
2520
+ );
2521
+ };
2522
+ var DEFAULT_TEST_ID = "bottom-tab-bar";
2523
+ var SLIDER_HEIGHT = 3;
2524
+ var styles9 = reactNative.StyleSheet.create({
2525
+ bar: { flexDirection: "row", borderTopWidth: reactNative.StyleSheet.hairlineWidth },
2526
+ slider: { position: "absolute", top: 0, height: SLIDER_HEIGHT }
2527
+ });
2528
+ var BottomTabBar = ({
2529
+ items,
2530
+ pathname,
2531
+ onNavigate,
2532
+ tabFeedback = "default" /* Default */,
2533
+ navigateHint = (label) => label,
2534
+ regionLabel = "",
2535
+ containerStyle,
2536
+ testID = DEFAULT_TEST_ID
2537
+ }) => {
2538
+ const { theme } = uiFeedback.useUi();
2539
+ const reducedMotion = useReducedMotion();
2540
+ const primary = theme.palette.primary["500"];
2541
+ const activeRoute = resolveActiveRoute(items, pathname);
2542
+ const activeIndex = items.findIndex((item) => item.route === activeRoute);
2543
+ const bar = tabFeedback === "sliding-bar" /* SlidingBar */ ? slidingBarPosition(activeIndex, items.length) : null;
2544
+ const barMs = resolveTabFeedback(tabFeedback, { active: true, pressed: false }, reducedMotion).durationMs;
2545
+ const colors = { active: primary, rest: theme.colors.textSecondary, pill: theme.colors.surfaceElevated };
2546
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2547
+ reactNative.View,
2548
+ {
2549
+ "aria-label": regionLabel,
2550
+ role: "navigation",
2551
+ style: [styles9.bar, { backgroundColor: theme.colors.surface, borderTopColor: theme.colors.border }, containerStyle],
2552
+ testID,
2553
+ children: [
2554
+ bar === null ? null : /* @__PURE__ */ jsxRuntime.jsx(
2555
+ reactNative.View,
2556
+ {
2557
+ pointerEvents: "none",
2558
+ style: [styles9.slider, { left: bar.left, width: bar.width, backgroundColor: primary }, webTransition("left", barMs, reducedMotion)]
2559
+ }
2560
+ ),
2561
+ items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
2562
+ BottomTab,
2563
+ {
2564
+ colors,
2565
+ isActive: item.route === activeRoute,
2566
+ item,
2567
+ kind: tabFeedback,
2568
+ navigateHint,
2569
+ reducedMotion,
2570
+ onPress: onNavigate
2571
+ },
2572
+ item.key
2573
+ ))
2574
+ ]
2575
+ }
2576
+ );
2577
+ };
2578
+
2223
2579
  exports.ACTIVE_BORDER_RADIUS = ACTIVE_BORDER_RADIUS;
2224
2580
  exports.ACTIVE_TINT_OPACITY = ACTIVE_TINT_OPACITY;
2225
2581
  exports.APP_SHELL_SUFFIX = APP_SHELL_SUFFIX;
2226
2582
  exports.AppShell = AppShell;
2227
2583
  exports.BASE_INDENT = BASE_INDENT;
2584
+ exports.BottomTabBar = BottomTabBar;
2228
2585
  exports.CHEVRON_ICON_SIZE = CHEVRON_ICON_SIZE;
2229
2586
  exports.CollapsedRail = CollapsedRail;
2230
2587
  exports.CommandPalette = CommandPalette;
@@ -2240,6 +2597,7 @@ exports.NAV_TEST_IDS = NAV_TEST_IDS;
2240
2597
  exports.Nav = Nav;
2241
2598
  exports.NavBar = NavBar;
2242
2599
  exports.NavExpandableItem = NavExpandableItem;
2600
+ exports.NavHoverFeedback = NavHoverFeedback;
2243
2601
  exports.NavOverflowMenu = NavOverflowMenu;
2244
2602
  exports.NavShell = NavShell;
2245
2603
  exports.PillNav = PillNav;
@@ -2248,6 +2606,7 @@ exports.RAIL_FULL_BREAKPOINT = RAIL_FULL_BREAKPOINT;
2248
2606
  exports.Sidebar = Sidebar;
2249
2607
  exports.SidebarChevron = SidebarChevron;
2250
2608
  exports.SidebarSearch = SidebarSearch;
2609
+ exports.TabFeedback = TabFeedback;
2251
2610
  exports.Topbar = Topbar;
2252
2611
  exports.accessibleNavItems = accessibleNavItems;
2253
2612
  exports.collapsedRailStyles = collapsedRailStyles;