@dloizides/ui-nav 1.10.2 → 1.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.11.0
4
+
5
+ - **Feature (an expandable nav group now auto-opens when the current route is inside it).**
6
+ `NavExpandableItem` used to mount every group collapsed (`useState(false)`), so on a fresh
7
+ page load the group containing the active route was closed and its active leaf was hidden.
8
+ A persistent-rail console that groups its nav by module (e.g. finreg-web's Payments / CRM /
9
+ Accounting rail) needs the active module's group open on load, the others collapsed — with
10
+ no way for the caller to express that, since expansion state was internal and uncontrolled.
11
+ - The group now initializes its expansion from whether the current route is inside it:
12
+ `useState(() => hasActiveDescendant(item, pathname))`, where `hasActiveDescendant` is true
13
+ when the item's own route is active (`isRouteActive`) or any descendant's is (recursive, so
14
+ it holds at any nesting depth). The group of the active route mounts EXPANDED (active leaf
15
+ visible + highlighted); a group with no active descendant mounts COLLAPSED — exactly the
16
+ prior behaviour.
17
+ - **No API change.** `NavItem` is unchanged — the behaviour is derived from the `pathname`
18
+ the component already receives. Consumers with no expandable groups, or whose groups hold no
19
+ active route, are byte-for-byte unaffected; all 7 portals gain the auto-open on their next
20
+ bump for free.
21
+ - Guarded by `src/navExpandableItem.test.tsx` (RED-first): the two "mounts EXPANDED" assertions
22
+ FAIL against the pre-change `useState(false)` (the active group's child is absent from the DOM)
23
+ and pass after; the "mounts COLLAPSED" + "header always renders" assertions lock the
24
+ backward-compatible half.
25
+
3
26
  ## 1.10.2
4
27
 
5
28
  - **Fix (the mobile nav DRAWER was not clickable — a tap on a nav item did not navigate, it
package/dist/index.d.mts CHANGED
@@ -898,6 +898,87 @@ interface NavExpandableItemProps {
898
898
  }
899
899
  declare const NavExpandableItem: ({ item, pathname, onNavigate, navigateHint, expandHint, collapseHint, renderChevron, depth, }: NavExpandableItemProps) => React.ReactElement;
900
900
 
901
+ /**
902
+ * Pure matching logic for the {@link CommandPalette}. Kept framework-free so the
903
+ * ranking/filtering is unit-testable without rendering: a command matches when
904
+ * every whitespace-separated token of the query appears (case-insensitively) in
905
+ * its haystack (label + hint + keywords). Requiring ALL tokens lets an operator
906
+ * narrow with "pay dash" the way a fuzzy launcher would, without a fuzzy lib.
907
+ */
908
+
909
+ /** One actionable destination/command shown in the palette. */
910
+ interface CommandItem {
911
+ /** Stable key + default testID. */
912
+ key: string;
913
+ /** Optional testID override (defaults to `key`). */
914
+ testID?: string;
915
+ /** Localized display label (what the operator reads). */
916
+ label: string;
917
+ /** Optional localized secondary line (e.g. the module it lives in). */
918
+ hint?: string;
919
+ /** Optional extra search terms not shown but matched (synonyms, route words). */
920
+ keywords?: readonly string[];
921
+ /** Invoked when the item is chosen (the consumer navigates / runs the action). */
922
+ onSelect: () => void;
923
+ /** Optional leading icon slot, same contract as `NavItem.renderIcon`. */
924
+ renderIcon?: (color: string, size: number) => React.ReactNode;
925
+ }
926
+ /**
927
+ * Filter `items` to those matching `query`. An empty/whitespace query returns
928
+ * the list unchanged (the palette opens showing everything). Order is preserved
929
+ * — the caller supplies items in a meaningful order (nav order).
930
+ */
931
+ declare function filterCommands(items: readonly CommandItem[], query: string): CommandItem[];
932
+
933
+ /**
934
+ * CommandPalette — the shared ⌘K launcher for `@dloizides/ui-nav`. An overlay
935
+ * (scrim + centred panel) with a search input over a keyboard-navigable list of
936
+ * {@link CommandItem}s — jump to any nav destination (or run any action) without
937
+ * hunting the rail. Web-first (RN-web), but degrades safely: when `open` is
938
+ * false it renders nothing.
939
+ *
940
+ * Package discipline (as the rest of ui-nav): NO FM / router / store / icon-set
941
+ * imports. Labels are pre-localized strings; icons are render slots; every colour
942
+ * comes from the `@dloizides/ui-feedback` UiProvider theme. The consumer owns the
943
+ * `open` state (pair with {@link useCommandPaletteHotkey}) and each item's
944
+ * `onSelect` (navigate / run) — the palette just closes after a choice.
945
+ */
946
+
947
+ /** Pre-localized labels for the palette chrome. */
948
+ interface CommandPaletteLabels {
949
+ /** Search input placeholder. */
950
+ placeholder: string;
951
+ /** Accessible name of the scrim (tap to close). */
952
+ closeLabel: string;
953
+ /** Accessible hint of the scrim (tap to close). */
954
+ closeHint: string;
955
+ /** Shown when the query matches nothing. */
956
+ emptyText: string;
957
+ /** Accessible label for the palette dialog landmark. */
958
+ regionLabel: string;
959
+ }
960
+ interface CommandPaletteProps {
961
+ /** Whether the palette overlay is shown. */
962
+ open: boolean;
963
+ /** The commands to search over, in a meaningful (nav) order. */
964
+ items: readonly CommandItem[];
965
+ /** Close the palette (scrim tap / Escape / after a selection). */
966
+ onClose: () => void;
967
+ /** Pre-localized chrome labels. */
968
+ labels: CommandPaletteLabels;
969
+ /** testID for the panel (defaults to `command-palette`). */
970
+ testID?: string;
971
+ }
972
+ /** The overlay palette. Renders null when closed. */
973
+ declare const CommandPalette: ({ open, items, onClose, labels, testID }: CommandPaletteProps) => React.ReactElement | null;
974
+
975
+ /**
976
+ * Call `onOpen` when the operator presses ⌘K (macOS) or Ctrl-K (Windows/Linux),
977
+ * anywhere in the document. `enabled=false` unbinds (e.g. when the shell has no
978
+ * palette configured), so the shortcut never fires without a handler behind it.
979
+ */
980
+ declare function useCommandPaletteHotkey(onOpen: () => void, enabled?: boolean): void;
981
+
901
982
  /**
902
983
  * Role-gated nav helpers — build a `NavItem[]` from a user's roles, reusing the
903
984
  * existing `resolveAccessibleRoutes` from `@dloizides/auth-web` (the same helper
@@ -1101,6 +1182,8 @@ declare const expandableStyles: {
1101
1182
  flexDirection: "row";
1102
1183
  alignItems: "center";
1103
1184
  paddingVertical: number;
1185
+ borderLeftWidth: number;
1186
+ borderLeftColor: string;
1104
1187
  };
1105
1188
  childItemText: {
1106
1189
  fontSize: number;
@@ -1114,6 +1197,7 @@ declare const expandableStyles: {
1114
1197
  };
1115
1198
  childrenContainer: {
1116
1199
  overflow: "hidden";
1200
+ marginBottom: number;
1117
1201
  };
1118
1202
  header: {
1119
1203
  borderRadius: number;
@@ -1121,11 +1205,21 @@ declare const expandableStyles: {
1121
1205
  alignItems: "center";
1122
1206
  paddingVertical: number;
1123
1207
  };
1208
+ sectionHeader: {
1209
+ marginTop: number;
1210
+ };
1124
1211
  headerText: {
1125
1212
  fontSize: number;
1126
1213
  fontWeight: "600";
1127
1214
  marginLeft: number;
1128
1215
  };
1216
+ sectionHeaderText: {
1217
+ fontSize: number;
1218
+ fontWeight: "700";
1219
+ marginLeft: number;
1220
+ textTransform: "uppercase";
1221
+ letterSpacing: number;
1222
+ };
1129
1223
  iconWrapper: {
1130
1224
  width: number;
1131
1225
  alignItems: "center";
@@ -1138,4 +1232,4 @@ declare const NAV_ICON_SIZE = 14;
1138
1232
  /** Chevron icon size for expandable sections. */
1139
1233
  declare const CHEVRON_ICON_SIZE = 12;
1140
1234
 
1141
- export { ACTIVE_BORDER_RADIUS, APP_SHELL_SUFFIX, type AccountPlan, type AccountState, AppShell, type AppShellProps, type AppShellWidth, BASE_INDENT, CHEVRON_ICON_SIZE, CollapsedRail, type CollapsedRailProps, type CollapsedRailRange, DEFAULT_COLLAPSED_RAIL_MAX, DarkModeControl, type DarkModeControlProps, type DarkModeOption, type DarkModeVariant, NAV_ICON_SIZE, NAV_LINK_GAP, NAV_TEST_IDS, Nav, NavBar, type NavBarProps, NavExpandableItem, type NavExpandableItemProps, type NavItem, type NavOrientation, NavOverflowMenu, type NavOverflowMenuProps, type NavProps, NavShell, type NavShellCollapsedRail, type NavShellLayout, type NavShellProps, type NavShellSideRail, type NavShellTopBar, type NavUser, PillNav, type PillNavProps, RAIL_FULL_BREAKPOINT, type RailMode, type ResolveRailModeArgs, type ShellMessage, Sidebar, type SidebarProps, Topbar, type TopbarAction, type TopbarProps, type TopbarUser, accessibleNavItems, collapsedRailStyles, darkModeStyles, expandableStyles, isRouteActive, navStyles, pillNavStyles, resolveContentMaxWidth, resolveRailMode, roleRoutesToNavItems, useContentMaxWidth };
1235
+ export { ACTIVE_BORDER_RADIUS, APP_SHELL_SUFFIX, type AccountPlan, type AccountState, AppShell, type AppShellProps, type AppShellWidth, BASE_INDENT, CHEVRON_ICON_SIZE, CollapsedRail, type CollapsedRailProps, type CollapsedRailRange, type CommandItem, CommandPalette, type CommandPaletteLabels, type CommandPaletteProps, DEFAULT_COLLAPSED_RAIL_MAX, DarkModeControl, type DarkModeControlProps, type DarkModeOption, type DarkModeVariant, NAV_ICON_SIZE, NAV_LINK_GAP, NAV_TEST_IDS, Nav, NavBar, type NavBarProps, NavExpandableItem, type NavExpandableItemProps, type NavItem, type NavOrientation, NavOverflowMenu, type NavOverflowMenuProps, type NavProps, NavShell, type NavShellCollapsedRail, type NavShellLayout, type NavShellProps, type NavShellSideRail, type NavShellTopBar, type NavUser, PillNav, type PillNavProps, RAIL_FULL_BREAKPOINT, type RailMode, type ResolveRailModeArgs, type ShellMessage, Sidebar, type SidebarProps, Topbar, type TopbarAction, type TopbarProps, type TopbarUser, accessibleNavItems, collapsedRailStyles, darkModeStyles, expandableStyles, filterCommands, isRouteActive, navStyles, pillNavStyles, resolveContentMaxWidth, resolveRailMode, roleRoutesToNavItems, useCommandPaletteHotkey, useContentMaxWidth };
package/dist/index.d.ts CHANGED
@@ -898,6 +898,87 @@ interface NavExpandableItemProps {
898
898
  }
899
899
  declare const NavExpandableItem: ({ item, pathname, onNavigate, navigateHint, expandHint, collapseHint, renderChevron, depth, }: NavExpandableItemProps) => React.ReactElement;
900
900
 
901
+ /**
902
+ * Pure matching logic for the {@link CommandPalette}. Kept framework-free so the
903
+ * ranking/filtering is unit-testable without rendering: a command matches when
904
+ * every whitespace-separated token of the query appears (case-insensitively) in
905
+ * its haystack (label + hint + keywords). Requiring ALL tokens lets an operator
906
+ * narrow with "pay dash" the way a fuzzy launcher would, without a fuzzy lib.
907
+ */
908
+
909
+ /** One actionable destination/command shown in the palette. */
910
+ interface CommandItem {
911
+ /** Stable key + default testID. */
912
+ key: string;
913
+ /** Optional testID override (defaults to `key`). */
914
+ testID?: string;
915
+ /** Localized display label (what the operator reads). */
916
+ label: string;
917
+ /** Optional localized secondary line (e.g. the module it lives in). */
918
+ hint?: string;
919
+ /** Optional extra search terms not shown but matched (synonyms, route words). */
920
+ keywords?: readonly string[];
921
+ /** Invoked when the item is chosen (the consumer navigates / runs the action). */
922
+ onSelect: () => void;
923
+ /** Optional leading icon slot, same contract as `NavItem.renderIcon`. */
924
+ renderIcon?: (color: string, size: number) => React.ReactNode;
925
+ }
926
+ /**
927
+ * Filter `items` to those matching `query`. An empty/whitespace query returns
928
+ * the list unchanged (the palette opens showing everything). Order is preserved
929
+ * — the caller supplies items in a meaningful order (nav order).
930
+ */
931
+ declare function filterCommands(items: readonly CommandItem[], query: string): CommandItem[];
932
+
933
+ /**
934
+ * CommandPalette — the shared ⌘K launcher for `@dloizides/ui-nav`. An overlay
935
+ * (scrim + centred panel) with a search input over a keyboard-navigable list of
936
+ * {@link CommandItem}s — jump to any nav destination (or run any action) without
937
+ * hunting the rail. Web-first (RN-web), but degrades safely: when `open` is
938
+ * false it renders nothing.
939
+ *
940
+ * Package discipline (as the rest of ui-nav): NO FM / router / store / icon-set
941
+ * imports. Labels are pre-localized strings; icons are render slots; every colour
942
+ * comes from the `@dloizides/ui-feedback` UiProvider theme. The consumer owns the
943
+ * `open` state (pair with {@link useCommandPaletteHotkey}) and each item's
944
+ * `onSelect` (navigate / run) — the palette just closes after a choice.
945
+ */
946
+
947
+ /** Pre-localized labels for the palette chrome. */
948
+ interface CommandPaletteLabels {
949
+ /** Search input placeholder. */
950
+ placeholder: string;
951
+ /** Accessible name of the scrim (tap to close). */
952
+ closeLabel: string;
953
+ /** Accessible hint of the scrim (tap to close). */
954
+ closeHint: string;
955
+ /** Shown when the query matches nothing. */
956
+ emptyText: string;
957
+ /** Accessible label for the palette dialog landmark. */
958
+ regionLabel: string;
959
+ }
960
+ interface CommandPaletteProps {
961
+ /** Whether the palette overlay is shown. */
962
+ open: boolean;
963
+ /** The commands to search over, in a meaningful (nav) order. */
964
+ items: readonly CommandItem[];
965
+ /** Close the palette (scrim tap / Escape / after a selection). */
966
+ onClose: () => void;
967
+ /** Pre-localized chrome labels. */
968
+ labels: CommandPaletteLabels;
969
+ /** testID for the panel (defaults to `command-palette`). */
970
+ testID?: string;
971
+ }
972
+ /** The overlay palette. Renders null when closed. */
973
+ declare const CommandPalette: ({ open, items, onClose, labels, testID }: CommandPaletteProps) => React.ReactElement | null;
974
+
975
+ /**
976
+ * Call `onOpen` when the operator presses ⌘K (macOS) or Ctrl-K (Windows/Linux),
977
+ * anywhere in the document. `enabled=false` unbinds (e.g. when the shell has no
978
+ * palette configured), so the shortcut never fires without a handler behind it.
979
+ */
980
+ declare function useCommandPaletteHotkey(onOpen: () => void, enabled?: boolean): void;
981
+
901
982
  /**
902
983
  * Role-gated nav helpers — build a `NavItem[]` from a user's roles, reusing the
903
984
  * existing `resolveAccessibleRoutes` from `@dloizides/auth-web` (the same helper
@@ -1101,6 +1182,8 @@ declare const expandableStyles: {
1101
1182
  flexDirection: "row";
1102
1183
  alignItems: "center";
1103
1184
  paddingVertical: number;
1185
+ borderLeftWidth: number;
1186
+ borderLeftColor: string;
1104
1187
  };
1105
1188
  childItemText: {
1106
1189
  fontSize: number;
@@ -1114,6 +1197,7 @@ declare const expandableStyles: {
1114
1197
  };
1115
1198
  childrenContainer: {
1116
1199
  overflow: "hidden";
1200
+ marginBottom: number;
1117
1201
  };
1118
1202
  header: {
1119
1203
  borderRadius: number;
@@ -1121,11 +1205,21 @@ declare const expandableStyles: {
1121
1205
  alignItems: "center";
1122
1206
  paddingVertical: number;
1123
1207
  };
1208
+ sectionHeader: {
1209
+ marginTop: number;
1210
+ };
1124
1211
  headerText: {
1125
1212
  fontSize: number;
1126
1213
  fontWeight: "600";
1127
1214
  marginLeft: number;
1128
1215
  };
1216
+ sectionHeaderText: {
1217
+ fontSize: number;
1218
+ fontWeight: "700";
1219
+ marginLeft: number;
1220
+ textTransform: "uppercase";
1221
+ letterSpacing: number;
1222
+ };
1129
1223
  iconWrapper: {
1130
1224
  width: number;
1131
1225
  alignItems: "center";
@@ -1138,4 +1232,4 @@ declare const NAV_ICON_SIZE = 14;
1138
1232
  /** Chevron icon size for expandable sections. */
1139
1233
  declare const CHEVRON_ICON_SIZE = 12;
1140
1234
 
1141
- export { ACTIVE_BORDER_RADIUS, APP_SHELL_SUFFIX, type AccountPlan, type AccountState, AppShell, type AppShellProps, type AppShellWidth, BASE_INDENT, CHEVRON_ICON_SIZE, CollapsedRail, type CollapsedRailProps, type CollapsedRailRange, DEFAULT_COLLAPSED_RAIL_MAX, DarkModeControl, type DarkModeControlProps, type DarkModeOption, type DarkModeVariant, NAV_ICON_SIZE, NAV_LINK_GAP, NAV_TEST_IDS, Nav, NavBar, type NavBarProps, NavExpandableItem, type NavExpandableItemProps, type NavItem, type NavOrientation, NavOverflowMenu, type NavOverflowMenuProps, type NavProps, NavShell, type NavShellCollapsedRail, type NavShellLayout, type NavShellProps, type NavShellSideRail, type NavShellTopBar, type NavUser, PillNav, type PillNavProps, RAIL_FULL_BREAKPOINT, type RailMode, type ResolveRailModeArgs, type ShellMessage, Sidebar, type SidebarProps, Topbar, type TopbarAction, type TopbarProps, type TopbarUser, accessibleNavItems, collapsedRailStyles, darkModeStyles, expandableStyles, isRouteActive, navStyles, pillNavStyles, resolveContentMaxWidth, resolveRailMode, roleRoutesToNavItems, useContentMaxWidth };
1235
+ export { ACTIVE_BORDER_RADIUS, APP_SHELL_SUFFIX, type AccountPlan, type AccountState, AppShell, type AppShellProps, type AppShellWidth, BASE_INDENT, CHEVRON_ICON_SIZE, CollapsedRail, type CollapsedRailProps, type CollapsedRailRange, type CommandItem, CommandPalette, type CommandPaletteLabels, type CommandPaletteProps, DEFAULT_COLLAPSED_RAIL_MAX, DarkModeControl, type DarkModeControlProps, type DarkModeOption, type DarkModeVariant, NAV_ICON_SIZE, NAV_LINK_GAP, NAV_TEST_IDS, Nav, NavBar, type NavBarProps, NavExpandableItem, type NavExpandableItemProps, type NavItem, type NavOrientation, NavOverflowMenu, type NavOverflowMenuProps, type NavProps, NavShell, type NavShellCollapsedRail, type NavShellLayout, type NavShellProps, type NavShellSideRail, type NavShellTopBar, type NavUser, PillNav, type PillNavProps, RAIL_FULL_BREAKPOINT, type RailMode, type ResolveRailModeArgs, type ShellMessage, Sidebar, type SidebarProps, Topbar, type TopbarAction, type TopbarProps, type TopbarUser, accessibleNavItems, collapsedRailStyles, darkModeStyles, expandableStyles, filterCommands, isRouteActive, navStyles, pillNavStyles, resolveContentMaxWidth, resolveRailMode, roleRoutesToNavItems, useCommandPaletteHotkey, useContentMaxWidth };
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ function isRouteActive(pathname, route) {
19
19
  return pathname === route || pathname.startsWith(`${route}/`);
20
20
  }
21
21
  var ACTIVE_BORDER_RADIUS = 4;
22
+ var ACTIVE_ACCENT_WIDTH = 3;
22
23
  var NAV_LINK_GAP = 4;
23
24
  var navStyles = reactNative.StyleSheet.create({
24
25
  // --- Sidebar ---
@@ -242,23 +243,39 @@ var collapsedRailStyles = reactNative.StyleSheet.create({
242
243
  slot: { alignItems: "center" }
243
244
  });
244
245
  var expandableStyles = reactNative.StyleSheet.create({
246
+ // Every leaf reserves the accent-bar gutter (a TRANSPARENT left border of the
247
+ // same width the active item colours in) so activation never shifts the row.
245
248
  childItem: {
246
- borderRadius: 4,
249
+ borderRadius: 6,
247
250
  flexDirection: "row",
248
251
  alignItems: "center",
249
- paddingVertical: 6
252
+ paddingVertical: 9,
253
+ borderLeftWidth: ACTIVE_ACCENT_WIDTH,
254
+ borderLeftColor: "transparent"
250
255
  },
251
256
  childItemText: { fontSize: 14 },
252
257
  childItemTextWithIcon: { fontSize: 14, marginLeft: 6 },
253
258
  chevron: { marginLeft: "auto" },
254
- childrenContainer: { overflow: "hidden" },
259
+ childrenContainer: { overflow: "hidden", marginBottom: 4 },
255
260
  header: {
256
- borderRadius: 4,
261
+ borderRadius: 6,
257
262
  flexDirection: "row",
258
263
  alignItems: "center",
259
264
  paddingVertical: 8
260
265
  },
266
+ // Depth-0 module groups render as docs-style SECTION HEADERS: extra top rhythm
267
+ // to separate one module from the next.
268
+ sectionHeader: { marginTop: 12 },
261
269
  headerText: { fontSize: 15, fontWeight: "600", marginLeft: 6 },
270
+ // The section-label look: small, uppercased, tracked, muted — the aml-screening
271
+ // docs treatment ("START HERE" / "REFERENCE"), kept on an interactive toggle.
272
+ sectionHeaderText: {
273
+ fontSize: 12,
274
+ fontWeight: "700",
275
+ marginLeft: 6,
276
+ textTransform: "uppercase",
277
+ letterSpacing: 0.8
278
+ },
262
279
  iconWrapper: { width: 20, alignItems: "center" }
263
280
  });
264
281
  var BASE_INDENT = 12;
@@ -289,6 +306,13 @@ function hoverTransitionStyle(reducedMotion) {
289
306
  function ariaCurrentProps(isActive) {
290
307
  return isActive ? { "aria-current": "page" } : {};
291
308
  }
309
+ function ariaExpandedProps(expanded) {
310
+ return { "aria-expanded": expanded };
311
+ }
312
+ function hasActiveDescendant(item, pathname) {
313
+ if (isRouteActive(pathname, item.route)) return true;
314
+ return (item.children ?? []).some((child) => hasActiveDescendant(child, pathname));
315
+ }
292
316
  var NavExpandableItem = ({
293
317
  item,
294
318
  pathname,
@@ -299,7 +323,7 @@ var NavExpandableItem = ({
299
323
  renderChevron,
300
324
  depth = 0
301
325
  }) => {
302
- const [expanded, setExpanded] = React2.useState(false);
326
+ const [expanded, setExpanded] = React2.useState(() => hasActiveDescendant(item, pathname));
303
327
  const [focused, setFocused] = React2.useState(false);
304
328
  const { theme } = uiFeedback.useUi();
305
329
  const colors = theme.colors;
@@ -308,9 +332,15 @@ var NavExpandableItem = ({
308
332
  const indent = depth * BASE_INDENT;
309
333
  const hasChildren = Array.isArray(item.children) && item.children.length > 0;
310
334
  const isActive = isRouteActive(pathname, item.route);
335
+ const isSectionHeader = hasChildren && depth === 0;
311
336
  const activeItemStyle = React2.useMemo(
312
- () => ({ backgroundColor: colors.border, borderRadius: ACTIVE_BORDER_RADIUS }),
313
- [colors.border]
337
+ () => ({
338
+ backgroundColor: colors.border,
339
+ borderRadius: ACTIVE_BORDER_RADIUS,
340
+ borderLeftWidth: ACTIVE_ACCENT_WIDTH,
341
+ borderLeftColor: primaryColor
342
+ }),
343
+ [colors.border, primaryColor]
314
344
  );
315
345
  const paddingStyle = React2.useMemo(() => ({ paddingLeft: indent + BASE_INDENT }), [indent]);
316
346
  const headerPaddingStyle = React2.useMemo(() => ({ paddingLeft: indent }), [indent]);
@@ -356,14 +386,29 @@ var NavExpandableItem = ({
356
386
  accessibilityLabel: item.label,
357
387
  accessibilityRole: "button",
358
388
  accessibilityState: { expanded },
359
- style: [expandableStyles.header, headerPaddingStyle, focusRingStyle(focused, primaryColor)],
389
+ style: [
390
+ expandableStyles.header,
391
+ isSectionHeader ? expandableStyles.sectionHeader : void 0,
392
+ headerPaddingStyle,
393
+ focusRingStyle(focused, primaryColor)
394
+ ],
360
395
  testID: item.testID ?? item.key,
361
396
  onBlur: () => setFocused(false),
362
397
  onFocus: () => setFocused(true),
363
398
  onPress: toggle,
399
+ ...ariaExpandedProps(expanded),
364
400
  children: [
365
- typeof item.renderIcon === "function" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: expandableStyles.iconWrapper, children: item.renderIcon(colors.text, NAV_ICON_SIZE) }) : null,
366
- /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [expandableStyles.headerText, { color: colors.text }], children: item.label }),
401
+ typeof item.renderIcon === "function" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: expandableStyles.iconWrapper, children: item.renderIcon(isSectionHeader ? colors.textSecondary : colors.text, NAV_ICON_SIZE) }) : null,
402
+ /* @__PURE__ */ jsxRuntime.jsx(
403
+ reactNative.Text,
404
+ {
405
+ style: [
406
+ isSectionHeader ? expandableStyles.sectionHeaderText : expandableStyles.headerText,
407
+ { color: isSectionHeader ? colors.textSecondary : colors.text }
408
+ ],
409
+ children: item.label
410
+ }
411
+ ),
367
412
  typeof renderChevron === "function" ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: expandableStyles.chevron, children: renderChevron(expanded, colors.textSecondary, CHEVRON_ICON_SIZE) }) : null
368
413
  ]
369
414
  }
@@ -1008,6 +1053,7 @@ var DRAWER_WIDTH = 280;
1008
1053
  var SCRIM_Z_INDEX = 1;
1009
1054
  var PANEL_Z_INDEX = 2;
1010
1055
  var NAV_ACTIVATABLE_SELECTOR = 'a, [role="button"], [role="link"]';
1056
+ var NAV_TOGGLE_SELECTOR = "[aria-expanded]";
1011
1057
  var DEFAULT_DRAWER_LABELS = {
1012
1058
  openLabel: "Menu",
1013
1059
  openHint: "Open the navigation menu",
@@ -1070,8 +1116,10 @@ var MobileDrawer = ({
1070
1116
  if (node === null || typeof node.addEventListener !== "function") return void 0;
1071
1117
  const handleClick = (event) => {
1072
1118
  const target = event.target;
1073
- const onNavItem = target !== null && target.closest(NAV_ACTIVATABLE_SELECTOR) !== null;
1074
- if (onNavItem) onClose();
1119
+ if (target === null) return;
1120
+ const activatable = target.closest(NAV_ACTIVATABLE_SELECTOR);
1121
+ const isToggle = activatable !== null && activatable.closest(NAV_TOGGLE_SELECTOR) !== null;
1122
+ if (activatable !== null && !isToggle) onClose();
1075
1123
  };
1076
1124
  node.addEventListener("click", handleClick);
1077
1125
  return () => node.removeEventListener("click", handleClick);
@@ -1484,6 +1532,159 @@ var DarkModeControl = ({
1484
1532
  }
1485
1533
  );
1486
1534
  };
1535
+
1536
+ // src/commandPaletteFilter.ts
1537
+ function haystack(item) {
1538
+ return [item.label, item.hint ?? "", ...item.keywords ?? []].join(" ").toLowerCase();
1539
+ }
1540
+ function filterCommands(items, query) {
1541
+ const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
1542
+ if (tokens.length === 0) return [...items];
1543
+ return items.filter((item) => {
1544
+ const hay = haystack(item);
1545
+ return tokens.every((token) => hay.includes(token));
1546
+ });
1547
+ }
1548
+ var SCRIM_COLOR2 = "rgba(0, 0, 0, 0.5)";
1549
+ var OVERLAY_Z_INDEX = 60;
1550
+ var PANEL_MAX_WIDTH = 560;
1551
+ var PANEL_TOP_OFFSET = 88;
1552
+ var LIST_MAX_HEIGHT = 360;
1553
+ var KEY_DOWN = "ArrowDown";
1554
+ var KEY_UP = "ArrowUp";
1555
+ var KEY_ENTER = "Enter";
1556
+ var KEY_ESCAPE = "Escape";
1557
+ var styles4 = reactNative.StyleSheet.create({
1558
+ overlay: { ...reactNative.StyleSheet.absoluteFillObject, zIndex: OVERLAY_Z_INDEX, alignItems: "center" },
1559
+ scrim: { ...reactNative.StyleSheet.absoluteFillObject, backgroundColor: SCRIM_COLOR2 },
1560
+ panel: {
1561
+ marginTop: PANEL_TOP_OFFSET,
1562
+ width: "92%",
1563
+ maxWidth: PANEL_MAX_WIDTH,
1564
+ borderRadius: 12,
1565
+ borderWidth: 1,
1566
+ overflow: "hidden"
1567
+ },
1568
+ input: { fontSize: 16, paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1 },
1569
+ list: { maxHeight: LIST_MAX_HEIGHT },
1570
+ row: { flexDirection: "row", alignItems: "center", paddingHorizontal: 16, paddingVertical: 12, columnGap: 10 },
1571
+ rowText: { flex: 1 },
1572
+ rowLabel: { fontSize: 15, fontWeight: "600" },
1573
+ rowHint: { fontSize: 12, marginTop: 2 },
1574
+ empty: { paddingHorizontal: 16, paddingVertical: 20, fontSize: 14 }
1575
+ });
1576
+ var PaletteRow = ({ item, selected, onChoose }) => {
1577
+ const { theme } = uiFeedback.useUi();
1578
+ const colors = theme.colors;
1579
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1580
+ reactNative.Pressable,
1581
+ {
1582
+ accessibilityHint: item.hint,
1583
+ accessibilityLabel: item.label,
1584
+ accessibilityRole: "button",
1585
+ accessibilityState: { selected },
1586
+ style: [styles4.row, selected ? { backgroundColor: colors.border } : void 0],
1587
+ testID: item.testID ?? item.key,
1588
+ onPress: () => onChoose(item),
1589
+ children: [
1590
+ typeof item.renderIcon === "function" ? item.renderIcon(colors.textSecondary, NAV_ICON_SIZE) : null,
1591
+ /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles4.rowText, children: [
1592
+ /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles4.rowLabel, { color: colors.text }], children: item.label }),
1593
+ item.hint ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles4.rowHint, { color: colors.textSecondary }], children: item.hint }) : null
1594
+ ] })
1595
+ ]
1596
+ }
1597
+ );
1598
+ };
1599
+ var CommandPalette = ({ open, items, onClose, labels, testID }) => {
1600
+ const { theme } = uiFeedback.useUi();
1601
+ const colors = theme.colors;
1602
+ const [query, setQuery] = React2.useState("");
1603
+ const [selected, setSelected] = React2.useState(0);
1604
+ const inputRef = React2.useRef(null);
1605
+ const results = React2.useMemo(() => filterCommands(items, query), [items, query]);
1606
+ React2.useEffect(() => {
1607
+ if (!open) return;
1608
+ setQuery("");
1609
+ setSelected(0);
1610
+ inputRef.current?.focus();
1611
+ }, [open]);
1612
+ React2.useEffect(() => {
1613
+ setSelected((prev) => Math.min(prev, Math.max(0, results.length - 1)));
1614
+ }, [results.length]);
1615
+ const choose = React2.useCallback(
1616
+ (item) => {
1617
+ item.onSelect();
1618
+ onClose();
1619
+ },
1620
+ [onClose]
1621
+ );
1622
+ const onKeyPress = React2.useCallback(
1623
+ (event) => {
1624
+ const key = event.nativeEvent.key;
1625
+ if (key === KEY_DOWN) setSelected((i) => Math.min(i + 1, results.length - 1));
1626
+ else if (key === KEY_UP) setSelected((i) => Math.max(i - 1, 0));
1627
+ else if (key === KEY_ESCAPE) onClose();
1628
+ else if (key === KEY_ENTER && results[selected]) choose(results[selected]);
1629
+ },
1630
+ [results, selected, choose, onClose]
1631
+ );
1632
+ if (!open) return null;
1633
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: styles4.overlay, children: [
1634
+ /* @__PURE__ */ jsxRuntime.jsx(
1635
+ reactNative.Pressable,
1636
+ {
1637
+ accessibilityHint: labels.closeHint,
1638
+ accessibilityLabel: labels.closeLabel,
1639
+ accessibilityRole: "button",
1640
+ style: styles4.scrim,
1641
+ testID: `${testID ?? "command-palette"}-scrim`,
1642
+ onPress: onClose
1643
+ }
1644
+ ),
1645
+ /* @__PURE__ */ jsxRuntime.jsxs(
1646
+ reactNative.View,
1647
+ {
1648
+ "aria-modal": true,
1649
+ "aria-label": labels.regionLabel,
1650
+ role: "dialog",
1651
+ style: [styles4.panel, { backgroundColor: colors.surface, borderColor: colors.border }],
1652
+ testID: testID ?? "command-palette",
1653
+ children: [
1654
+ /* @__PURE__ */ jsxRuntime.jsx(
1655
+ reactNative.TextInput,
1656
+ {
1657
+ ref: inputRef,
1658
+ accessibilityLabel: labels.placeholder,
1659
+ placeholder: labels.placeholder,
1660
+ placeholderTextColor: colors.textSecondary,
1661
+ style: [styles4.input, { color: colors.text, borderBottomColor: colors.border }],
1662
+ testID: `${testID ?? "command-palette"}-input`,
1663
+ value: query,
1664
+ onChangeText: setQuery,
1665
+ onKeyPress
1666
+ }
1667
+ ),
1668
+ results.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: [styles4.empty, { color: colors.textSecondary }], children: labels.emptyText }) : /* @__PURE__ */ jsxRuntime.jsx(reactNative.ScrollView, { keyboardShouldPersistTaps: "handled", style: styles4.list, children: results.map((item, index) => /* @__PURE__ */ jsxRuntime.jsx(PaletteRow, { item, selected: index === selected, onChoose: choose }, item.key)) })
1669
+ ]
1670
+ }
1671
+ )
1672
+ ] });
1673
+ };
1674
+ var LAUNCH_KEY = "k";
1675
+ function useCommandPaletteHotkey(onOpen, enabled = true) {
1676
+ React2.useEffect(() => {
1677
+ if (!enabled || typeof document === "undefined") return void 0;
1678
+ const handler = (event) => {
1679
+ const isLauncher = event.key.toLowerCase() === LAUNCH_KEY && (event.metaKey || event.ctrlKey);
1680
+ if (!isLauncher) return;
1681
+ event.preventDefault();
1682
+ onOpen();
1683
+ };
1684
+ document.addEventListener("keydown", handler);
1685
+ return () => document.removeEventListener("keydown", handler);
1686
+ }, [onOpen, enabled]);
1687
+ }
1487
1688
  function roleRoutesToNavItems(routes, translate) {
1488
1689
  return routes.map((entry) => ({
1489
1690
  key: entry.role,
@@ -1501,6 +1702,7 @@ exports.AppShell = AppShell;
1501
1702
  exports.BASE_INDENT = BASE_INDENT;
1502
1703
  exports.CHEVRON_ICON_SIZE = CHEVRON_ICON_SIZE;
1503
1704
  exports.CollapsedRail = CollapsedRail;
1705
+ exports.CommandPalette = CommandPalette;
1504
1706
  exports.DEFAULT_COLLAPSED_RAIL_MAX = DEFAULT_COLLAPSED_RAIL_MAX;
1505
1707
  exports.DarkModeControl = DarkModeControl;
1506
1708
  exports.NAV_ICON_SIZE = NAV_ICON_SIZE;
@@ -1519,12 +1721,14 @@ exports.accessibleNavItems = accessibleNavItems;
1519
1721
  exports.collapsedRailStyles = collapsedRailStyles;
1520
1722
  exports.darkModeStyles = darkModeStyles;
1521
1723
  exports.expandableStyles = expandableStyles;
1724
+ exports.filterCommands = filterCommands;
1522
1725
  exports.isRouteActive = isRouteActive;
1523
1726
  exports.navStyles = navStyles;
1524
1727
  exports.pillNavStyles = pillNavStyles;
1525
1728
  exports.resolveContentMaxWidth = resolveContentMaxWidth;
1526
1729
  exports.resolveRailMode = resolveRailMode;
1527
1730
  exports.roleRoutesToNavItems = roleRoutesToNavItems;
1731
+ exports.useCommandPaletteHotkey = useCommandPaletteHotkey;
1528
1732
  exports.useContentMaxWidth = useContentMaxWidth;
1529
1733
  //# sourceMappingURL=index.js.map
1530
1734
  //# sourceMappingURL=index.js.map