pixelize-design-library 2.4.2-beta.29 → 2.4.2-beta.30

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.
Files changed (26) hide show
  1. package/dist/Components/AppSwitcher/AppMark.d.ts +19 -0
  2. package/dist/Components/AppSwitcher/AppMark.js +62 -0
  3. package/dist/Components/AppSwitcher/AppSwitcher.d.ts +4 -0
  4. package/dist/Components/AppSwitcher/AppSwitcher.js +315 -0
  5. package/dist/Components/AppSwitcher/AppSwitcherProps.d.ts +64 -0
  6. package/dist/Components/AppSwitcher/AppSwitcherProps.js +86 -0
  7. package/dist/Components/AppSwitcher/useAppSwitcherShortcut.d.ts +3 -0
  8. package/dist/Components/AppSwitcher/useAppSwitcherShortcut.js +31 -0
  9. package/dist/Components/AppearanceSettings/AppearanceSettings.js +2 -1
  10. package/dist/Components/SelectV2/SelectV2.js +13 -5
  11. package/dist/Theme/tokens/builders/outlineRung.d.ts +8 -0
  12. package/dist/Theme/tokens/builders/outlineRung.js +21 -0
  13. package/dist/Theme/tokens/builders/rowStates.d.ts +13 -0
  14. package/dist/Theme/tokens/builders/rowStates.js +19 -0
  15. package/dist/esm/Components/AppSwitcher/AppMark.js +56 -0
  16. package/dist/esm/Components/AppSwitcher/AppSwitcher.js +280 -0
  17. package/dist/esm/Components/AppSwitcher/AppSwitcherProps.js +78 -0
  18. package/dist/esm/Components/AppSwitcher/useAppSwitcherShortcut.js +27 -0
  19. package/dist/esm/Components/AppearanceSettings/AppearanceSettings.js +2 -1
  20. package/dist/esm/Components/SelectV2/SelectV2.js +13 -5
  21. package/dist/esm/Theme/tokens/builders/outlineRung.js +20 -0
  22. package/dist/esm/Theme/tokens/builders/rowStates.js +15 -0
  23. package/dist/esm/index.js +7 -1
  24. package/dist/index.d.ts +7 -1
  25. package/dist/index.js +18 -4
  26. package/package.json +6 -2
@@ -43,7 +43,7 @@ const react_2 = require("@chakra-ui/react");
43
43
  const lucide_react_1 = require("lucide-react");
44
44
  const useCustomTheme_1 = require("../../Theme/useCustomTheme");
45
45
  const placeholderInk_1 = require("../../Theme/tokens/builders/placeholderInk");
46
- const outlineRung_1 = require("../../Theme/tokens/builders/outlineRung");
46
+ const rowStates_1 = require("../../Theme/tokens/builders/rowStates");
47
47
  const scrollbar_1 = require("../../Theme/tokens/builders/scrollbar");
48
48
  const fieldStyles_1 = require("../Common/fieldStyles");
49
49
  const FormLabel_1 = require("../Common/FormLabel");
@@ -58,10 +58,16 @@ const SIZE_MAP = {
58
58
  const asIdSet = (values) => new Set((values !== null && values !== void 0 ? values : []).map((v) => v.toString()));
59
59
  // Derived from the option's own id (not its index) so it stays stable across filtering/reordering.
60
60
  const optionDomId = (listboxId, id) => `${listboxId}-option-${id.toString().replace(/[^a-zA-Z0-9_-]/g, "-")}`;
61
- const OptionRow = react_1.default.memo(function OptionRow({ option, index, listboxId, isSelected, isActive, isMulti, theme, activeBg, renderOption, onSelect, onActivate, }) {
61
+ const OptionRow = react_1.default.memo(function OptionRow({ option, index, listboxId, isSelected, isActive, isMulti, theme, backgrounds, renderOption, onSelect, onActivate, }) {
62
62
  return ((0, jsx_runtime_1.jsxs)(react_2.Box, { id: optionDomId(listboxId, option.id), "data-index": index, role: "option", "aria-selected": isSelected, "aria-disabled": option.isDisabled, onMouseEnter: () => onActivate(index), onClick: () => onSelect(option), mx: 1.5, px: 2.5, py: 1.5, borderRadius: "md", cursor: option.isDisabled ? "not-allowed" : "pointer", opacity: option.isDisabled ? 0.4 : 1, display: "flex", alignItems: "center", gap: 2, fontSize: "0.8125rem", fontWeight: isSelected && !isMulti ? 600 : 500, color: isSelected && !isMulti
63
63
  ? theme.colors.accentText
64
- : theme.colors.text[700], bg: isActive ? activeBg : "transparent", transition: "background 0.12s ease, color 0.12s ease", children: [isMulti ? ((0, jsx_runtime_1.jsx)(react_2.Checkbox, { isChecked: isSelected, isDisabled: option.isDisabled, pointerEvents: "none", tabIndex: -1 })) : null, renderOption ? ((0, jsx_runtime_1.jsx)(react_2.Box, { flex: 1, minW: 0, children: renderOption(option, { isSelected, isActive }) })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [option.icon ? ((0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", display: "inline-flex", flexShrink: 0, children: option.icon })) : null, (0, jsx_runtime_1.jsxs)(react_2.Box, { flex: 1, minW: 0, isTruncated: true, children: [(0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", isTruncated: true, children: option.label }), option.description ? ((0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", display: "block", fontSize: "0.75rem", fontWeight: 400, color: theme.colors.textMuted, isTruncated: true, children: option.description })) : null] })] }))] }));
64
+ : theme.colors.text[700], bg: isSelected && !isMulti
65
+ ? isActive
66
+ ? backgrounds.selectedActive
67
+ : backgrounds.selected
68
+ : isActive
69
+ ? backgrounds.active
70
+ : "transparent", transition: "background 0.12s ease, color 0.12s ease", children: [isMulti ? ((0, jsx_runtime_1.jsx)(react_2.Checkbox, { isChecked: isSelected, isDisabled: option.isDisabled, pointerEvents: "none", tabIndex: -1 })) : null, renderOption ? ((0, jsx_runtime_1.jsx)(react_2.Box, { flex: 1, minW: 0, children: renderOption(option, { isSelected, isActive }) })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [option.icon ? ((0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", display: "inline-flex", flexShrink: 0, children: option.icon })) : null, (0, jsx_runtime_1.jsxs)(react_2.Box, { flex: 1, minW: 0, isTruncated: true, children: [(0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", isTruncated: true, children: option.label }), option.description ? ((0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", display: "block", fontSize: "0.75rem", fontWeight: 400, color: theme.colors.textMuted, isTruncated: true, children: option.description })) : null] })] }))] }));
65
71
  });
66
72
  function SelectV2(props) {
67
73
  var _a, _b;
@@ -235,7 +241,9 @@ function SelectV2(props) {
235
241
  const activeOption = filteredOptions[activeIndex];
236
242
  const activeDescendantId = activeOption ? optionDomId(listboxId, activeOption.id) : undefined;
237
243
  const listScrollStyles = (0, react_1.useMemo)(() => (0, scrollbar_1.scrollbarStyles)(theme.colors, "overlay"), [theme]);
238
- const activeOptionBg = (0, react_1.useMemo)(() => (0, outlineRung_1.resolveHoverTint)(theme.colors.primary[500], theme.colors.backgroundColor.light), [theme]);
244
+ // primary[500] is the filled-button rung, so resolveHoverTint short-circuited and every hovered
245
+ // row became a solid brand fill under unresolved ink.
246
+ const rowBackgrounds = (0, react_1.useMemo)(() => (0, rowStates_1.listRowBackgrounds)(theme.colors), [theme]);
239
247
  const defaultTriggerContent = () => {
240
248
  var _a;
241
249
  if (props.isMulti) {
@@ -255,6 +263,6 @@ function SelectV2(props) {
255
263
  setActiveIndex(0);
256
264
  }, "aria-activedescendant": activeDescendantId, "aria-label": "Filter options" }) })), (0, jsx_runtime_1.jsxs)(react_2.Box, { id: listboxId, ref: listRef, role: "listbox", "aria-multiselectable": props.isMulti || undefined, maxH: maxMenuHeight, overflowY: "auto", py: 1, sx: listScrollStyles, children: [filteredOptions.length === 0 && ((0, jsx_runtime_1.jsx)(react_2.Box, { px: 3, py: 2, fontSize: "0.8125rem", color: theme.colors.textMuted, children: "No Options" })), filteredOptions.map((option, index) => {
257
265
  var _a;
258
- return ((0, jsx_runtime_1.jsx)(OptionRow, { option: option, index: index, listboxId: listboxId, isSelected: selectedIds.has(option.id.toString()), isActive: index === activeIndex, isMulti: (_a = props.isMulti) !== null && _a !== void 0 ? _a : false, theme: theme, activeBg: activeOptionBg, renderOption: renderOption, onSelect: handleSelect, onActivate: setActiveIndex }, option.id));
266
+ return ((0, jsx_runtime_1.jsx)(OptionRow, { option: option, index: index, listboxId: listboxId, isSelected: selectedIds.has(option.id.toString()), isActive: index === activeIndex, isMulti: (_a = props.isMulti) !== null && _a !== void 0 ? _a : false, theme: theme, backgrounds: rowBackgrounds, renderOption: renderOption, onSelect: handleSelect, onActivate: setActiveIndex }, option.id));
259
267
  })] })] }) })] }), error && (0, jsx_runtime_1.jsx)(ErrorMessage_1.default, { errorMessage: errorMessage }), helperText && !error && (0, jsx_runtime_1.jsx)(HelperText_1.default, { helperText: helperText })] }));
260
268
  }
@@ -68,3 +68,11 @@ export declare function resolveHoverTint(seed: string, restSurface: string): str
68
68
  * their muted surface.
69
69
  */
70
70
  export declare function settleAcross(hex: string, surfaces: readonly string[], target: number): string;
71
+ /**
72
+ * A pure-grey surface sitting `target` contrast away from `backdrop`. Binary search, not a walk:
73
+ * `liftToContrast` cannot move a seed already at lightness 0 or 100, and a pure-white panel seeded
74
+ * itself straight to black. Neutral on purpose — the light palettes are hand-authored and tinted,
75
+ * so lifting one of their surface rungs turns a selected row into a saturated brand block, while
76
+ * the derived dark palettes land on grey and look right.
77
+ */
78
+ export declare function neutralStep(backdrop: string, target: number): string;
@@ -7,6 +7,7 @@ exports.softenToContrast = softenToContrast;
7
7
  exports.withOutlineRung = withOutlineRung;
8
8
  exports.resolveHoverTint = resolveHoverTint;
9
9
  exports.settleAcross = settleAcross;
10
+ exports.neutralStep = neutralStep;
10
11
  const color_1 = require("./color");
11
12
  /**
12
13
  * The floor a hover/active state's step off its own resting surface must clear to be seen at
@@ -144,3 +145,23 @@ function settleAcross(hex, surfaces, target) {
144
145
  }
145
146
  return settled;
146
147
  }
148
+ /**
149
+ * A pure-grey surface sitting `target` contrast away from `backdrop`. Binary search, not a walk:
150
+ * `liftToContrast` cannot move a seed already at lightness 0 or 100, and a pure-white panel seeded
151
+ * itself straight to black. Neutral on purpose — the light palettes are hand-authored and tinted,
152
+ * so lifting one of their surface rungs turns a selected row into a saturated brand block, while
153
+ * the derived dark palettes land on grey and look right.
154
+ */
155
+ function neutralStep(backdrop, target) {
156
+ const grey = (l) => (0, color_1.withLightness)("#808080", l, 0);
157
+ let lo = (0, color_1.hexToHsl)(backdrop).l;
158
+ let hi = (0, color_1.relativeLuminance)(backdrop) < 0.5 ? 100 : 0;
159
+ for (let i = 0; i < 20; i += 1) {
160
+ const mid = (lo + hi) / 2;
161
+ if ((0, color_1.contrastRatio)(grey(mid), backdrop) < target)
162
+ lo = mid;
163
+ else
164
+ hi = mid;
165
+ }
166
+ return grey(hi);
167
+ }
@@ -0,0 +1,13 @@
1
+ import type { PaletteProps } from "../types";
2
+ export interface RowStateBackgrounds {
3
+ active: string;
4
+ selected: string;
5
+ selectedActive: string;
6
+ }
7
+ /**
8
+ * The three backgrounds a selectable list row can rest on. Every rung is NEUTRAL and pale, so a
9
+ * row's ink stays the canvas ink `withSurfaceRungs` already settled. Seeding a row state from
10
+ * `primary[500]` instead — the filled-button rung — is what painted SelectV2's hovered rows a
11
+ * solid brand fill under unreadable text.
12
+ */
13
+ export declare const listRowBackgrounds: (colors: PaletteProps) => RowStateBackgrounds;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listRowBackgrounds = void 0;
4
+ const outlineRung_1 = require("./outlineRung");
5
+ /**
6
+ * The three backgrounds a selectable list row can rest on. Every rung is NEUTRAL and pale, so a
7
+ * row's ink stays the canvas ink `withSurfaceRungs` already settled. Seeding a row state from
8
+ * `primary[500]` instead — the filled-button rung — is what painted SelectV2's hovered rows a
9
+ * solid brand fill under unreadable text.
10
+ */
11
+ const listRowBackgrounds = (colors) => {
12
+ const selected = colors.backgroundColor.tertiary;
13
+ return {
14
+ active: (0, outlineRung_1.resolveHoverTint)(colors.gray[50], colors.backgroundColor.light),
15
+ selected,
16
+ selectedActive: (0, outlineRung_1.resolveHoverTint)(selected, selected),
17
+ };
18
+ };
19
+ exports.listRowBackgrounds = listRowBackgrounds;
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { isValidElement } from "react";
3
+ import { Box, Flex, Image } from "@chakra-ui/react";
4
+ import { useCustomTheme } from "../../Theme/useCustomTheme.js";
5
+ import { mix } from "../../Theme/tokens/builders/color.js";
6
+ import { onFilled } from "../../Theme/tokens/builders/accentText.js";
7
+ import { liftToContrast } from "../../Theme/tokens/builders/outlineRung.js";
8
+ import { AA_NORMAL_TEXT } from "../../Theme/tokens/builders/accentText.js";
9
+ import { CATEGORICAL_SERIES } from "../../Theme/tokens/builders/chartColorsFromTheme.js";
10
+ import { resolveAvatarFill } from "../../Theme/tokens/builders/statusTone.js";
11
+ import { avatarColor } from "../OrgSwitcher/OrgSwitcherProps.js";
12
+ import { appInitials } from "./AppSwitcherProps.js";
13
+ // The app's own hue at 85% strength — the solid mark, softened just off full saturation.
14
+ const FILL_STRENGTH = 0.85;
15
+ /**
16
+ * One hue per app. `avatarColor` hashes into an 8-hue series, so six apps collide more often than
17
+ * not — Billing and Account shipped identical marks. Assigned over the WHOLE list, never the
18
+ * filtered one, so searching cannot repaint a mark.
19
+ */
20
+ export const assignAppHues = (apps, mode) => {
21
+ const ramp = CATEGORICAL_SERIES[mode].map(resolveAvatarFill);
22
+ const taken = new Set(apps.flatMap((a) => (a.color ? [a.color] : [])));
23
+ return apps.map((app) => {
24
+ if (app.color)
25
+ return app.color;
26
+ const seed = avatarColor(app.id || app.name, mode);
27
+ const start = Math.max(0, ramp.indexOf(seed));
28
+ for (let i = 0; i < ramp.length; i += 1) {
29
+ const next = ramp[(start + i) % ramp.length];
30
+ if (!taken.has(next)) {
31
+ taken.add(next);
32
+ return next;
33
+ }
34
+ }
35
+ // More apps than the series has hues; past this point a repeat is unavoidable.
36
+ return seed;
37
+ });
38
+ };
39
+ // Softening costs the fill contrast, so it is walked back until a WHITE glyph clears AA — every
40
+ // mark keeps the same white icon instead of some flipping to black.
41
+ export const appTint = (hue, surface, white) => liftToContrast(mix(hue, surface, 1 - FILL_STRENGTH), white, AA_NORMAL_TEXT);
42
+ export const appTintInk = (fill, white, black) => onFilled(fill, white, black);
43
+ const AppMark = ({ app, hue, boxSize, iconSize, muted = false, }) => {
44
+ const { colors } = useCustomTheme();
45
+ if (app.iconUrl) {
46
+ return (_jsx(Image, { src: app.iconUrl, alt: "", boxSize: boxSize, borderRadius: "lg", objectFit: "cover", flexShrink: 0, opacity: muted ? 0.55 : 1 }));
47
+ }
48
+ const surface = colors.backgroundColor.light;
49
+ const fill = muted ? colors.backgroundColor.quaternary : appTint(hue, surface, colors.white);
50
+ const ink = muted ? colors.textMuted : appTintInk(fill, colors.white, colors.black);
51
+ // lucide icons are forwardRef objects, not functions — a typeof check silently drops them.
52
+ const isElement = isValidElement(app.icon);
53
+ const Icon = !isElement && app.icon ? app.icon : null;
54
+ return (_jsx(Flex, { align: "center", justify: "center", boxSize: boxSize, borderRadius: "lg", flexShrink: 0, bg: fill, color: ink, fontSize: `${Math.round(iconSize * 0.72)}px`, fontWeight: 700, "aria-hidden": true, children: Icon ? (_jsx(Icon, { size: iconSize, color: ink })) : isValidElement(app.icon) ? (_jsx(Box, { display: "flex", color: ink, children: app.icon })) : (appInitials(app.name)) }));
55
+ };
56
+ export default AppMark;
@@ -0,0 +1,280 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { Box, Flex, Input, InputGroup, InputLeftElement, Popover, PopoverBody, PopoverContent, PopoverTrigger, Skeleton, Text, useDisclosure, } from "@chakra-ui/react";
4
+ import { Grip, Lock, Search } from "lucide-react";
5
+ import { useCustomTheme } from "../../Theme/useCustomTheme.js";
6
+ import { chartMode } from "../../Theme/tokens/builders/chartColorsFromTheme.js";
7
+ import { AA_NORMAL_TEXT } from "../../Theme/tokens/builders/accentText.js";
8
+ import { liftToContrast, neutralStep, resolveHoverTint, ruleInk, AA_NON_TEXT, CHROME_RULE, MIN_PERCEPTIBLE_STEP, } from "../../Theme/tokens/builders/outlineRung.js";
9
+ import AppMark, { assignAppHues } from "./AppMark.js";
10
+ import { filterApps, formatShortcut, } from "./AppSwitcherProps.js";
11
+ import { useAppSwitcherShortcut } from "./useAppSwitcherShortcut.js";
12
+ const COLUMNS = 2;
13
+ const HEADING = "Your apps";
14
+ const s = { trigger: "36px", mark: "32px", icon: 17, name: "0.875rem", sub: "0.75rem", row: "52px" };
15
+ const warned = new Set();
16
+ const warnOnce = (key, message) => {
17
+ if (process.env.NODE_ENV === "production" || warned.has(key))
18
+ return;
19
+ warned.add(key);
20
+ console.error(`[AppSwitcher] ${message}`);
21
+ };
22
+ const validate = (apps) => {
23
+ const seen = new Set();
24
+ apps.forEach((app, i) => {
25
+ var _a;
26
+ if (!app.id)
27
+ warnOnce(`id-${i}`, `apps[${i}] ("${(_a = app.name) !== null && _a !== void 0 ? _a : "unnamed"}") has no id.`);
28
+ else if (seen.has(app.id))
29
+ warnOnce(`dup-${app.id}`, `duplicate app id "${app.id}".`);
30
+ else
31
+ seen.add(app.id);
32
+ if (!app.url && !app.locked)
33
+ warnOnce(`url-${app.id || i}`, `app "${app.name}" has no url; it cannot be opened.`);
34
+ });
35
+ };
36
+ // Past a pointer's pause threshold, so crossing the launcher on the way elsewhere does not open it,
37
+ // but short enough that deliberately resting on it feels instant.
38
+ const HOVER_OPEN_DELAY = 450;
39
+ // Long enough to travel the gap between the launcher and the panel below it.
40
+ const HOVER_CLOSE_GRACE = 250;
41
+ // Flip back to true to restore `searchThreshold` opening the field on its own.
42
+ const SEARCH_OPENS_BY_COUNT = false;
43
+ const AppSwitcher = ({ apps, currentAppId, onSelect, onOpenChange, isSearchable, searchThreshold = 8, shortcut = null, showShortcutHint = true, hideWhenAlone = true, emptyMessage = "No app matches that name.", footer, ariaLabel = "Switch app", panelWidth, maxHeight = "24rem", isLoading = false, }) => {
44
+ const theme = useCustomTheme();
45
+ const { isOpen, onOpen, onClose } = useDisclosure();
46
+ const [query, setQuery] = useState("");
47
+ const [activeIndex, setActiveIndex] = useState(0);
48
+ const searchRef = useRef(null);
49
+ const triggerRef = useRef(null);
50
+ const panelRef = useRef(null);
51
+ const itemRefs = useRef([]);
52
+ const typeahead = useRef({ buffer: "", at: 0 });
53
+ const hoverTimer = useRef();
54
+ const openedByHover = useRef(false);
55
+ useEffect(() => validate(apps), [apps]);
56
+ // Search is off while the panel's design settles: the app count alone no longer opens it. An
57
+ // explicit `isSearchable` still wins, so nothing about the prop changed.
58
+ const showSearch = isSearchable !== null && isSearchable !== void 0 ? isSearchable : (SEARCH_OPENS_BY_COUNT && apps.length >= searchThreshold);
59
+ const hues = useMemo(() => assignAppHues(apps, chartMode(theme.colors)), [apps, theme.colors]);
60
+ const visible = useMemo(() => filterApps(apps, query), [apps, query]);
61
+ const setOpen = useCallback((next) => {
62
+ if (next)
63
+ onOpen();
64
+ else
65
+ onClose();
66
+ onOpenChange === null || onOpenChange === void 0 ? void 0 : onOpenChange(next);
67
+ }, [onOpen, onClose, onOpenChange]);
68
+ const cancelHoverTimer = useCallback(() => {
69
+ if (hoverTimer.current)
70
+ clearTimeout(hoverTimer.current);
71
+ hoverTimer.current = undefined;
72
+ }, []);
73
+ useEffect(() => cancelHoverTimer, [cancelHoverTimer]);
74
+ // Touch and stylus report no hover; a timer there would open the panel on a tap-and-hold.
75
+ const pointerHovers = () => {
76
+ var _a;
77
+ return typeof window !== "undefined" &&
78
+ ((_a = window.matchMedia) === null || _a === void 0 ? void 0 : _a.call(window, "(hover: hover) and (pointer: fine)").matches);
79
+ };
80
+ const handleHoverStart = useCallback(() => {
81
+ if (isOpen || !pointerHovers())
82
+ return;
83
+ cancelHoverTimer();
84
+ hoverTimer.current = setTimeout(() => {
85
+ openedByHover.current = true;
86
+ setOpen(true);
87
+ }, HOVER_OPEN_DELAY);
88
+ }, [isOpen, cancelHoverTimer, setOpen]);
89
+ // A click-opened panel is sticky, and a keyboard user inside it is not "hovering away".
90
+ const handleHoverEnd = useCallback(() => {
91
+ cancelHoverTimer();
92
+ if (!isOpen || !openedByHover.current)
93
+ return;
94
+ hoverTimer.current = setTimeout(() => {
95
+ var _a;
96
+ if ((_a = panelRef.current) === null || _a === void 0 ? void 0 : _a.contains(document.activeElement))
97
+ return;
98
+ openedByHover.current = false;
99
+ setOpen(false);
100
+ setQuery("");
101
+ }, HOVER_CLOSE_GRACE);
102
+ }, [isOpen, cancelHoverTimer, setOpen]);
103
+ const closeAndRefocus = useCallback(() => {
104
+ var _a;
105
+ setOpen(false);
106
+ setQuery("");
107
+ (_a = triggerRef.current) === null || _a === void 0 ? void 0 : _a.focus();
108
+ }, [setOpen]);
109
+ useAppSwitcherShortcut(shortcut, () => setOpen(true), !isOpen);
110
+ useEffect(() => {
111
+ if (!isOpen)
112
+ setQuery("");
113
+ setActiveIndex(0);
114
+ }, [isOpen]);
115
+ useEffect(() => setActiveIndex(0), [query]);
116
+ const focusItem = useCallback((next) => {
117
+ var _a;
118
+ setActiveIndex(next);
119
+ (_a = itemRefs.current[next]) === null || _a === void 0 ? void 0 : _a.focus();
120
+ }, []);
121
+ // Read the focused row, not state: two keydowns in one React batch would both compute from a
122
+ // stale activeIndex, so holding an arrow key silently dropped moves.
123
+ const currentIndex = useCallback(() => {
124
+ const focused = itemRefs.current.findIndex((el) => el === document.activeElement);
125
+ return focused === -1 ? activeIndex : focused;
126
+ }, [activeIndex]);
127
+ const step = useCallback((delta) => {
128
+ const count = visible.length;
129
+ if (count === 0)
130
+ return;
131
+ focusItem(((currentIndex() + delta) % count + count) % count);
132
+ }, [currentIndex, visible.length, focusItem]);
133
+ const jumpToLetter = useCallback((char) => {
134
+ const now = Date.now();
135
+ const buffer = now - typeahead.current.at < 600 ? typeahead.current.buffer + char : char;
136
+ typeahead.current = { buffer, at: now };
137
+ const at = currentIndex();
138
+ const from = buffer.length > 1 ? at : at + 1;
139
+ const order = visible.map((_, i) => (from + i) % visible.length);
140
+ const hit = order.find((i) => visible[i].name.toLowerCase().startsWith(buffer));
141
+ if (hit !== undefined)
142
+ focusItem(hit);
143
+ }, [visible, currentIndex, focusItem]);
144
+ // Chakra parks focus on the panel itself, where no arrow handler lives; route the first press in.
145
+ const handlePanelKeyDown = useCallback((e) => {
146
+ if (itemRefs.current.includes(document.activeElement))
147
+ return;
148
+ if (visible.length === 0)
149
+ return;
150
+ if (["ArrowDown", "ArrowRight", "Home"].includes(e.key)) {
151
+ e.preventDefault();
152
+ focusItem(0);
153
+ }
154
+ else if (["ArrowUp", "ArrowLeft", "End"].includes(e.key)) {
155
+ e.preventDefault();
156
+ focusItem(visible.length - 1);
157
+ }
158
+ else if (e.key === "Escape") {
159
+ e.preventDefault();
160
+ closeAndRefocus();
161
+ }
162
+ }, [visible.length, focusItem, closeAndRefocus]);
163
+ const handleItemKeyDown = useCallback((e, app) => {
164
+ if (e.key === "ArrowDown") {
165
+ e.preventDefault();
166
+ step(COLUMNS);
167
+ }
168
+ else if (e.key === "ArrowUp") {
169
+ e.preventDefault();
170
+ step(-COLUMNS);
171
+ }
172
+ else if (e.key === "ArrowRight") {
173
+ e.preventDefault();
174
+ step(1);
175
+ }
176
+ else if (e.key === "ArrowLeft") {
177
+ e.preventDefault();
178
+ step(-1);
179
+ }
180
+ else if (e.key === "Home") {
181
+ e.preventDefault();
182
+ focusItem(0);
183
+ }
184
+ else if (e.key === "End") {
185
+ e.preventDefault();
186
+ focusItem(visible.length - 1);
187
+ }
188
+ else if (e.key === "Escape") {
189
+ e.preventDefault();
190
+ closeAndRefocus();
191
+ }
192
+ else if (e.key === " " && !app.locked) {
193
+ e.preventDefault();
194
+ e.currentTarget.click();
195
+ }
196
+ else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey)
197
+ jumpToLetter(e.key.toLowerCase());
198
+ }, [step, focusItem, visible.length, closeAndRefocus, jumpToLetter]);
199
+ const handleSelect = (app, e) => {
200
+ if (app.locked) {
201
+ e.preventDefault();
202
+ return;
203
+ }
204
+ onSelect === null || onSelect === void 0 ? void 0 : onSelect(app, e);
205
+ if (!e.defaultPrevented)
206
+ setOpen(false);
207
+ };
208
+ const nothingToSwitchTo = apps.length === 0 || (hideWhenAlone && apps.every((a) => a.id === currentAppId));
209
+ if (nothingToSwitchTo)
210
+ return null;
211
+ const rowHoverBg = resolveHoverTint(theme.colors.gray[50], theme.colors.backgroundColor.light);
212
+ const footerRule = ruleInk(theme.colors.boxborder[200], [theme.colors.backgroundColor.subtle], CHROME_RULE);
213
+ // Neutral, not `backgroundColor.tertiary`: that rung is brand-tinted in the hand-authored light
214
+ // palettes, so lifting it painted the row a saturated block. The rail identifies the row, so the
215
+ // fill only has to be perceptible — pushed further it is a mid-grey slab that reads disabled.
216
+ const currentBg = neutralStep(theme.colors.backgroundColor.light, MIN_PERCEPTIBLE_STEP);
217
+ // The brand rail is the whole marker — no ring, no badge. `accentText` already clears AA as ink
218
+ // on the canvas; lifting it to AA_NON_TEXT here guarantees it on the row's own fill too, for a
219
+ // brand whose accent is pale.
220
+ const currentBar = liftToContrast(theme.colors.accentText, currentBg, AA_NON_TEXT);
221
+ // A derived surface is outside what `withSurfaceRungs` settled, so its ink is resolved here.
222
+ const currentName = liftToContrast(theme.colors.text[900], currentBg, AA_NORMAL_TEXT);
223
+ const currentSub = liftToContrast(theme.colors.textMuted, currentBg, AA_NORMAL_TEXT);
224
+ const width = panelWidth !== null && panelWidth !== void 0 ? panelWidth : "28rem";
225
+ const hint = shortcut && showShortcutHint ? formatShortcut(shortcut) : null;
226
+ return (_jsxs(Popover, { isOpen: isOpen, onOpen: () => setOpen(true), onClose: () => { setOpen(false); setQuery(""); }, placement: "bottom-end", initialFocusRef: showSearch ? searchRef : undefined, isLazy: true, children: [_jsx(PopoverTrigger, { children: _jsx(Flex, { ref: triggerRef, as: "button", type: "button", "aria-label": ariaLabel, align: "center", justify: "center", boxSize: s.trigger, borderRadius: "lg", border: "1px solid", borderColor: isOpen ? theme.colors.boxborder[500] : theme.colors.boxborder[200], bg: isOpen ? theme.colors.backgroundColor.tertiary : "transparent", color: isOpen ? theme.colors.text[900] : theme.colors.textMuted, cursor: "pointer", transition: "background 0.15s ease, border-color 0.15s ease", _hover: { bg: rowHoverBg, color: theme.colors.text[900] }, onMouseEnter: handleHoverStart, onMouseLeave: handleHoverEnd, onClick: () => { openedByHover.current = false; cancelHoverTimer(); }, onKeyDown: (e) => {
227
+ if (!isOpen) {
228
+ if (e.key === "ArrowDown") {
229
+ e.preventDefault();
230
+ setOpen(true);
231
+ }
232
+ return;
233
+ }
234
+ handlePanelKeyDown(e);
235
+ }, children: _jsx(Grip, { size: s.icon + 1 }) }) }), _jsx(PopoverContent, { ref: panelRef, onMouseEnter: cancelHoverTimer, onMouseLeave: handleHoverEnd, w: width, maxW: "calc(100vw - 1.5rem)", textAlign: "start", onKeyDown: handlePanelKeyDown, bg: theme.colors.backgroundColor.light, borderRadius: "xl", overflow: "hidden", boxShadow: `0 10px 30px ${theme.colors.boxShadow.default}`, _focus: { outline: "none", boxShadow: `0 10px 30px ${theme.colors.boxShadow.default}` }, children: _jsxs(PopoverBody, { p: 0, children: [_jsxs(Flex, { align: "center", gap: 2, px: 3, pt: 3, pb: showSearch ? 2 : 1, children: [_jsx(Text, { fontSize: "0.6875rem", fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: theme.colors.textMuted, children: HEADING }), _jsx(Box, { flex: "1" }), hint && (_jsx(Text, { as: "kbd", fontSize: "0.6875rem", fontFamily: "inherit", color: theme.colors.textMuted, border: "1px solid", borderRadius: "base", px: 1.5, py: 0.5, children: hint }))] }), showSearch && (_jsx(Box, { px: 3, pb: 2, children: _jsxs(InputGroup, { size: "sm", children: [_jsx(InputLeftElement, { pointerEvents: "none", children: _jsx(Search, { size: 15, color: theme.colors.textMuted }) }), _jsx(Input, { ref: searchRef, value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search apps", borderRadius: "lg", "aria-label": "Search apps", onKeyDown: (e) => {
236
+ if (e.key === "ArrowDown" && visible.length > 0) {
237
+ e.preventDefault();
238
+ focusItem(0);
239
+ }
240
+ else if (e.key === "Escape") {
241
+ e.preventDefault();
242
+ if (query)
243
+ setQuery("");
244
+ else
245
+ closeAndRefocus();
246
+ }
247
+ } })] }) })), _jsxs(Box, { role: "menu", "aria-label": HEADING, maxH: maxHeight, overflowY: "auto", px: 2, pb: 2, display: "grid", gridTemplateColumns: `repeat(${COLUMNS}, minmax(0, 1fr))`, gap: "2px", children: [isLoading
248
+ ? [0, 1, 2, 3].map((i) => (_jsxs(Flex, { align: "center", gap: 3, px: 2, py: 2, minH: s.row, children: [_jsx(Skeleton, { boxSize: s.mark, borderRadius: "lg" }), _jsx(Skeleton, { h: "0.75rem", flex: "1", borderRadius: "base" })] }, i)))
249
+ : visible.map((app, idx) => {
250
+ var _a;
251
+ const isCurrent = app.id === currentAppId;
252
+ const interactive = !isCurrent && !app.locked;
253
+ return (_jsxs(Flex, { as: interactive ? "a" : "div", role: "menuitem", ref: (el) => { itemRefs.current[idx] = el; }, tabIndex: idx === activeIndex ? 0 : -1, "aria-current": isCurrent ? "true" : undefined, "aria-disabled": app.locked ? true : undefined, title: app.locked ? ((_a = app.lockedReason) !== null && _a !== void 0 ? _a : `${app.name} is not available on your plan`) : undefined, ...(interactive
254
+ ? {
255
+ href: app.url,
256
+ target: "_blank",
257
+ rel: "noopener noreferrer",
258
+ }
259
+ : {}), align: "center", gap: 3, minH: s.row, px: 2, py: 2, borderRadius: "lg", textDecoration: "none", cursor: interactive ? "pointer" : "default", bg: isCurrent ? currentBg : "transparent", position: "relative",
260
+ // Full height, taking the row's own `lg` radius on its outer corners so the
261
+ // rail follows the row's curve instead of cutting across it.
262
+ _before: isCurrent
263
+ ? {
264
+ content: '""',
265
+ position: "absolute",
266
+ insetInlineStart: 0,
267
+ insetBlock: 0,
268
+ width: "3px",
269
+ borderStartStartRadius: "lg",
270
+ borderEndStartRadius: "lg",
271
+ bg: currentBar,
272
+ }
273
+ : undefined, transition: "background 0.12s ease", _hover: interactive ? { bg: rowHoverBg } : undefined, onClick: (e) => handleSelect(app, e), onKeyDown: (e) => handleItemKeyDown(e, app), children: [_jsx(AppMark, { app: app, hue: hues[apps.indexOf(app)], boxSize: s.mark, iconSize: s.icon, muted: app.locked }), _jsxs(Box, { flex: "1", minW: 0, children: [_jsxs(Flex, { align: "center", gap: 1.5, children: [_jsx(Text, { fontSize: s.name, fontWeight: 600, color: app.locked
274
+ ? theme.colors.textMuted
275
+ : isCurrent
276
+ ? currentName
277
+ : theme.colors.text[900], overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", children: app.name }), app.badge !== undefined && !isCurrent && (_jsx(Text, { fontSize: "0.625rem", fontWeight: 600, color: theme.colors.semanticText.info, flexShrink: 0, children: app.badge }))] }), app.description && (_jsx(Text, { fontSize: s.sub, color: isCurrent ? currentSub : theme.colors.textMuted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", children: app.description }))] }), app.locked && (_jsx(Box, { as: Lock, size: 14, color: theme.colors.textMuted, flexShrink: 0 }))] }, app.id || app.name));
278
+ }), !isLoading && visible.length === 0 && (_jsx(Box, { gridColumn: `span ${COLUMNS}`, px: 2, py: 6, textAlign: "center", children: _jsx(Text, { fontSize: s.name, color: theme.colors.textMuted, children: emptyMessage }) }))] }), footer && (_jsx(Flex, { align: "center", gap: 1.5, px: 3, py: 2.5, borderTop: "1px solid", borderTopColor: footerRule, bg: theme.colors.backgroundColor.subtle, children: footer }))] }) })] }));
279
+ };
280
+ export default AppSwitcher;
@@ -0,0 +1,78 @@
1
+ const NON_WORD = /[^\p{L}\p{N}]+/u;
2
+ /** Up to two initials, so a mark stays legible without an icon. */
3
+ export const appInitials = (name) => {
4
+ const words = name.trim().split(NON_WORD).filter(Boolean);
5
+ if (words.length === 0)
6
+ return "?";
7
+ if (words.length === 1)
8
+ return words[0].slice(0, 2).toUpperCase();
9
+ return (words[0][0] + words[1][0]).toUpperCase();
10
+ };
11
+ export const filterApps = (apps, query) => {
12
+ const q = query.trim().toLowerCase();
13
+ if (!q)
14
+ return apps;
15
+ return apps.filter((app) => {
16
+ var _a, _b;
17
+ return [app.name, (_a = app.description) !== null && _a !== void 0 ? _a : "", ...((_b = app.keywords) !== null && _b !== void 0 ? _b : [])]
18
+ .join(" ")
19
+ .toLowerCase()
20
+ .includes(q);
21
+ });
22
+ };
23
+ /** "mod" is Cmd on Apple platforms and Ctrl everywhere else. */
24
+ export const matchesShortcut = (event, shortcut, isApple = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform)) => {
25
+ const parts = shortcut.toLowerCase().split("+").map((p) => p.trim()).filter(Boolean);
26
+ if (parts.length === 0)
27
+ return false;
28
+ const key = parts[parts.length - 1];
29
+ const mods = new Set(parts.slice(0, -1));
30
+ const wantsMod = mods.has("mod");
31
+ const wantsCtrl = mods.has("ctrl") || (wantsMod && !isApple);
32
+ const wantsMeta = mods.has("cmd") || mods.has("meta") || (wantsMod && isApple);
33
+ if (event.ctrlKey !== wantsCtrl)
34
+ return false;
35
+ if (event.metaKey !== wantsMeta)
36
+ return false;
37
+ if (event.shiftKey !== mods.has("shift"))
38
+ return false;
39
+ if (event.altKey !== mods.has("alt"))
40
+ return false;
41
+ return event.key.toLowerCase() === key;
42
+ };
43
+ const APPLE_SYMBOLS = { mod: "⌘", cmd: "⌘", meta: "⌘", ctrl: "⌃", shift: "⇧", alt: "⌥" };
44
+ const OTHER_LABELS = { mod: "Ctrl", cmd: "Win", meta: "Win", ctrl: "Ctrl", shift: "Shift", alt: "Alt" };
45
+ export const formatShortcut = (shortcut, isApple = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform)) => {
46
+ const parts = shortcut.toLowerCase().split("+").map((p) => p.trim()).filter(Boolean);
47
+ const table = isApple ? APPLE_SYMBOLS : OTHER_LABELS;
48
+ const rendered = parts.map((p, i) => { var _a; return i === parts.length - 1 ? p.toUpperCase() : ((_a = table[p]) !== null && _a !== void 0 ? _a : p.toUpperCase()); });
49
+ return isApple ? rendered.join("") : rendered.join("+");
50
+ };
51
+ /**
52
+ * Derives every app's origin from the host the consumer is already served on, so one deployment
53
+ * variable keeps dev, test and prod in step. An app may still carry its own `url`, which wins.
54
+ */
55
+ export const buildSuiteApps = (apps, { baseUrl, baseSubdomain }) => {
56
+ let base;
57
+ try {
58
+ base = new URL(baseUrl.trim());
59
+ }
60
+ catch {
61
+ throw new Error(`[AppSwitcher] buildSuiteApps: baseUrl ${JSON.stringify(baseUrl)} is not an absolute URL.`);
62
+ }
63
+ const labels = base.hostname.split(".");
64
+ const at = labels.indexOf(baseSubdomain);
65
+ return apps.map(({ subdomain, url, ...app }) => {
66
+ if (url)
67
+ return { ...app, url: url.trim() };
68
+ // Without the label there is nothing to swap, and every app would silently share one origin.
69
+ if (at === -1) {
70
+ throw new Error(`[AppSwitcher] buildSuiteApps: ${JSON.stringify(baseSubdomain)} is not a label in ` +
71
+ `"${base.hostname}", so ${JSON.stringify(app.name)} has no derivable url. ` +
72
+ `Give that app an explicit url, or pass a baseUrl that contains the label.`);
73
+ }
74
+ const next = new URL(base.toString());
75
+ next.hostname = labels.map((l, i) => (i === at ? subdomain : l)).join(".");
76
+ return { ...app, url: next.origin };
77
+ });
78
+ };
@@ -0,0 +1,27 @@
1
+ import { useEffect } from "react";
2
+ import { matchesShortcut } from "./AppSwitcherProps.js";
3
+ const isTypingTarget = (target) => {
4
+ if (!(target instanceof HTMLElement))
5
+ return false;
6
+ if (target.isContentEditable)
7
+ return true;
8
+ return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
9
+ };
10
+ /** Fires `onTrigger` on the shortcut. Inert when `shortcut` is null, and never in SSR. */
11
+ export const useAppSwitcherShortcut = (shortcut, onTrigger, enabled = true) => {
12
+ useEffect(() => {
13
+ if (!shortcut || !enabled || typeof window === "undefined")
14
+ return undefined;
15
+ const handler = (event) => {
16
+ if (event.defaultPrevented || isTypingTarget(event.target))
17
+ return;
18
+ if (!matchesShortcut(event, shortcut))
19
+ return;
20
+ event.preventDefault();
21
+ onTrigger();
22
+ };
23
+ window.addEventListener("keydown", handler);
24
+ return () => window.removeEventListener("keydown", handler);
25
+ }, [shortcut, enabled, onTrigger]);
26
+ };
27
+ export default useAppSwitcherShortcut;
@@ -5,13 +5,14 @@ import { ThemesList } from "../../Theme/index.js";
5
5
  import { ThemeSwitcher } from "../../Theme/provider/index.js";
6
6
  import { FONT_SIZE_OPTIONS } from "../../Theme/provider/fontSize.js";
7
7
  import { lightPalettes } from "../../Theme/tokens/brands/index.js";
8
+ import { CHROME_RULE, ruleInk } from "../../Theme/tokens/builders/outlineRung.js";
8
9
  import { useCustomTheme } from "../../Theme/useCustomTheme.js";
9
10
  import SelectV2 from "../SelectV2/SelectV2.js";
10
11
  const FieldLabel = ({ children }) => {
11
12
  const { colors } = useCustomTheme();
12
13
  return (_jsx(Text, { fontSize: "sm", fontWeight: "medium", color: colors.textMuted, mb: 2, children: children }));
13
14
  };
14
- const brandSwatch = (name, colors) => (_jsx(Box, { w: "14px", h: "14px", borderRadius: "full", bg: lightPalettes[name].primary[500], border: "1px solid", borderColor: colors.boxborder[500] }));
15
+ const brandSwatch = (name, colors) => (_jsx(Box, { w: "14px", h: "14px", borderRadius: "full", bg: lightPalettes[name].primary[500], border: "1px solid", borderColor: ruleInk(colors.boxborder[200], [colors.backgroundColor.light, colors.backgroundColor.tertiary], CHROME_RULE) }));
15
16
  export const AppearanceSettings = ({ brand, fontSize, onBrandChange, onFontSizeChange, showBrand = true, showColorMode = true, showFontSize = true, size = "sm", labels, }) => {
16
17
  var _a, _b, _c;
17
18
  const { colors } = useCustomTheme();