@igstack/app-catalog-frontend-core 4.0.0-alpha-20260920014237 → 4.0.0-alpha-20260920124718
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/esm/__tests__/modules/appCatalog/hooks/useUrlSyncedState.test.d.ts +1 -0
- package/dist/esm/modules/appCatalog/hooks/useUrlSyncedState.js +5 -5
- package/dist/esm/modules/appCatalog/hooks/useUrlSyncedState.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.d.ts +33 -8
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +182 -141
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +8 -3
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/index.css +4 -1
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/hooks/useUrlSyncedState.test.tsx +63 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +3 -1
- package/src/index.css +4 -1
- package/src/modules/appCatalog/hooks/useUrlSyncedState.ts +23 -5
- package/src/modules/appCatalog/ui/components/QuickJumpBar.tsx +252 -146
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +14 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -17,19 +17,19 @@ function useUrlSyncedState({
|
|
|
17
17
|
return defaultValue;
|
|
18
18
|
});
|
|
19
19
|
useEffect(() => {
|
|
20
|
+
const currentSearch = router.state.location.search;
|
|
20
21
|
const encodedValue = encode ? encode(state) : state;
|
|
21
|
-
if (encodedValue ===
|
|
22
|
-
const currentPath = router.state.location.pathname;
|
|
22
|
+
if (encodedValue === currentSearch[key]) return;
|
|
23
23
|
navigate({
|
|
24
|
-
to:
|
|
24
|
+
to: router.state.location.pathname,
|
|
25
25
|
search: {
|
|
26
|
-
...
|
|
26
|
+
...currentSearch,
|
|
27
27
|
[key]: encodedValue
|
|
28
28
|
},
|
|
29
29
|
replace: true
|
|
30
30
|
// Use replace to avoid polluting history
|
|
31
31
|
});
|
|
32
|
-
}, [state, key, encode, navigate, router
|
|
32
|
+
}, [state, key, encode, navigate, router, search]);
|
|
33
33
|
return [state, setState];
|
|
34
34
|
}
|
|
35
35
|
export {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useUrlSyncedState.js","sources":["../../../../../src/modules/appCatalog/hooks/useUrlSyncedState.ts"],"sourcesContent":["import { useNavigate, useRouter, useSearch } from '@tanstack/react-router'\nimport { useEffect, useState } from 'react'\n\n/**\n * Options for useUrlSyncedState hook\n */\nexport interface UseUrlSyncedStateOptions<T> {\n /** The URL search parameter key */\n key: string\n /** Default value when URL param is not present */\n defaultValue: T\n /** Optional decoder to transform URL string to state value */\n decode?: (urlValue: string) => T\n /** Optional encoder to transform state value to URL string (return undefined to remove param) */\n encode?: (stateValue: T) => string | undefined\n}\n\n/**\n * Hook for managing state that is synced bidirectionally with URL search params.\n *\n * Key features:\n * - Initializes from URL once on mount\n * - Async sync state → URL after initialization to prevent UI sluggishness\n * - Uses replace: true to avoid polluting browser history\n *\n * @example\n * ```tsx\n * // Boolean state\n * const [recentMode, setRecentMode] = useUrlSyncedState({\n * key: 'recent',\n * defaultValue: false,\n * decode: (value) => value === '1',\n * encode: (value) => value ? '1' : undefined,\n * })\n *\n * // Object state\n * const [filters, setFilters] = useUrlSyncedState({\n * key: 'filters',\n * defaultValue: {},\n * decode: (value) => parseFilters(value),\n * encode: (value) => encodeFilters(value),\n * })\n * ```\n */\nexport function useUrlSyncedState<T>({\n key,\n defaultValue,\n decode,\n encode,\n}: UseUrlSyncedStateOptions<T>): [T, (value: T) => void] {\n const navigate = useNavigate()\n const router = useRouter()\n const search = useSearch({ strict: false })\n\n // Initialize state from URL on mount (once only)\n const [state, setState] = useState<T>(() => {\n const urlValue = (search as Record<string, unknown>)[key]\n if (urlValue !== undefined) {\n return decode ? decode(String(urlValue)) : (urlValue as T)\n }\n return defaultValue\n })\n\n // Sync state to URL (async side effect). The equality check below is what\n // prevents redundant writes / URL pollution from default values — so state\n // changes made from the default (e.g. the first keystroke in an empty search)\n // are still persisted to the URL and survive navigation/remounts.\n useEffect(() => {\n // Encode state value for URL\n const encodedValue = encode ? encode(state) : (state as string | undefined)\n\n // Check if already in sync\n if (encodedValue ===
|
|
1
|
+
{"version":3,"file":"useUrlSyncedState.js","sources":["../../../../../src/modules/appCatalog/hooks/useUrlSyncedState.ts"],"sourcesContent":["import { useNavigate, useRouter, useSearch } from '@tanstack/react-router'\nimport { useEffect, useState } from 'react'\n\n/**\n * Options for useUrlSyncedState hook\n */\nexport interface UseUrlSyncedStateOptions<T> {\n /** The URL search parameter key */\n key: string\n /** Default value when URL param is not present */\n defaultValue: T\n /** Optional decoder to transform URL string to state value */\n decode?: (urlValue: string) => T\n /** Optional encoder to transform state value to URL string (return undefined to remove param) */\n encode?: (stateValue: T) => string | undefined\n}\n\n/**\n * Hook for managing state that is synced bidirectionally with URL search params.\n *\n * Key features:\n * - Initializes from URL once on mount\n * - Async sync state → URL after initialization to prevent UI sluggishness\n * - Uses replace: true to avoid polluting browser history\n *\n * @example\n * ```tsx\n * // Boolean state\n * const [recentMode, setRecentMode] = useUrlSyncedState({\n * key: 'recent',\n * defaultValue: false,\n * decode: (value) => value === '1',\n * encode: (value) => value ? '1' : undefined,\n * })\n *\n * // Object state\n * const [filters, setFilters] = useUrlSyncedState({\n * key: 'filters',\n * defaultValue: {},\n * decode: (value) => parseFilters(value),\n * encode: (value) => encodeFilters(value),\n * })\n * ```\n */\nexport function useUrlSyncedState<T>({\n key,\n defaultValue,\n decode,\n encode,\n}: UseUrlSyncedStateOptions<T>): [T, (value: T) => void] {\n const navigate = useNavigate()\n const router = useRouter()\n const search = useSearch({ strict: false })\n\n // Initialize state from URL on mount (once only)\n const [state, setState] = useState<T>(() => {\n const urlValue = (search as Record<string, unknown>)[key]\n if (urlValue !== undefined) {\n return decode ? decode(String(urlValue)) : (urlValue as T)\n }\n return defaultValue\n })\n\n // Sync state to URL (async side effect). The equality check below is what\n // prevents redundant writes / URL pollution from default values — so state\n // changes made from the default (e.g. the first keystroke in an empty search)\n // are still persisted to the URL and survive navigation/remounts.\n useEffect(() => {\n // Compare against the LIVE router location, not the `useSearch()` snapshot\n // above: that snapshot comes from the resolved route match and still holds\n // the pre-navigation params while a navigation this effect issued is\n // settling. `encode` is normally an inline arrow, so this effect re-runs on\n // every render — and against the stale snapshot the sync check never\n // matched, so each render queued another navigate() and each navigate()\n // caused another render. 50 of those is React's \"Maximum update depth\n // exceeded\", which unmounted the subtree mid-interaction (#152).\n //\n // Spreading the live params matters for the same reason: two instances of\n // this hook writing different keys in one tick would otherwise each spread\n // their own stale snapshot and drop the other's param.\n const currentSearch = router.state.location.search as Record<\n string,\n unknown\n >\n\n // Encode state value for URL\n const encodedValue = encode ? encode(state) : (state as string | undefined)\n\n // Check if already in sync\n if (encodedValue === currentSearch[key]) return\n\n navigate({\n to: router.state.location.pathname,\n search: {\n ...currentSearch,\n [key]: encodedValue,\n },\n replace: true, // Use replace to avoid polluting history\n })\n // `search` stays in the deps so an external URL change (back/forward) still\n // re-runs this and re-asserts state → URL.\n }, [state, key, encode, navigate, router, search])\n\n return [state, setState]\n}\n"],"names":[],"mappings":";;AA4CO,SAAS,kBAAqB;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAyD;AACvD,QAAM,WAAW,YAAA;AACjB,QAAM,SAAS,UAAA;AACf,QAAM,SAAS,UAAU,EAAE,QAAQ,OAAO;AAG1C,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAY,MAAM;AAC1C,UAAM,WAAY,OAAmC,GAAG;AACxD,QAAI,aAAa,QAAW;AAC1B,aAAO,SAAS,OAAO,OAAO,QAAQ,CAAC,IAAK;AAAA,IAC9C;AACA,WAAO;AAAA,EACT,CAAC;AAMD,YAAU,MAAM;AAad,UAAM,gBAAgB,OAAO,MAAM,SAAS;AAM5C,UAAM,eAAe,SAAS,OAAO,KAAK,IAAK;AAG/C,QAAI,iBAAiB,cAAc,GAAG,EAAG;AAEzC,aAAS;AAAA,MACP,IAAI,OAAO,MAAM,SAAS;AAAA,MAC1B,QAAQ;AAAA,QACN,GAAG;AAAA,QACH,CAAC,GAAG,GAAG;AAAA,MAAA;AAAA,MAET,SAAS;AAAA;AAAA,IAAA,CACV;AAAA,EAGH,GAAG,CAAC,OAAO,KAAK,QAAQ,UAAU,QAAQ,MAAM,CAAC;AAEjD,SAAO,CAAC,OAAO,QAAQ;AACzB;"}
|
|
@@ -1,25 +1,50 @@
|
|
|
1
1
|
import { Resource } from '@igstack/app-catalog-backend-core';
|
|
2
|
+
/** Whether the open button should stand down for this app's Quick Jump. */
|
|
3
|
+
export declare function hasQuickJumps(app: Resource): boolean;
|
|
2
4
|
/**
|
|
3
|
-
* The leap, as
|
|
4
|
-
* and crouches on `:active`. Deliberately NOT an on-click animation —
|
|
5
|
-
* links open a new tab, which takes focus the same instant, so a keyframe
|
|
6
|
-
* on click runs in a backgrounded tab and the user only sees it on the way
|
|
5
|
+
* The leap, as a free-standing button does it: it lifts onto its toes on
|
|
6
|
+
* `:hover` and crouches on `:active`. Deliberately NOT an on-click animation —
|
|
7
|
+
* these links open a new tab, which takes focus the same instant, so a keyframe
|
|
8
|
+
* fired on click runs in a backgrounded tab and the user only sees it on the way
|
|
9
|
+
* back.
|
|
7
10
|
*
|
|
8
11
|
* The transition has to name `translate` and `scale`: in Tailwind v4 those
|
|
9
12
|
* utilities set those separate properties, which a `transform` transition never
|
|
10
13
|
* sees.
|
|
14
|
+
*
|
|
15
|
+
* This belongs to the open button ALONE. Jump is welded into a bordered frame,
|
|
16
|
+
* where the same lift covered the shell's top border along its whole width and
|
|
17
|
+
* the crouch opened 3.5px of card above it while overhanging the bottom. A
|
|
18
|
+
* segment presses inward instead — see `JUMP_FILL`.
|
|
11
19
|
*/
|
|
12
20
|
export declare const LEAP = "origin-bottom transition-[background-color,translate,scale] duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]";
|
|
13
21
|
/**
|
|
14
|
-
* The brand fill for
|
|
15
|
-
* `--primary` under `--primary-foreground` measures 3.8:1 — below WCAG AA
|
|
16
|
-
*
|
|
17
|
-
*
|
|
22
|
+
* The brand fill for a button that leaves for the app. Darkened, because
|
|
23
|
+
* `--primary` under `--primary-foreground` measures 3.8:1 — below WCAG AA. 90%
|
|
24
|
+
* measured 4.56:1, which clears AA by 0.06 at a size where bold does not count
|
|
25
|
+
* as large text; 85% measures 5.19:1.
|
|
18
26
|
*
|
|
19
27
|
* A token-level fix would be better and is a brand decision, not this
|
|
20
28
|
* component's: every filled button in the app inherits the same ratio.
|
|
21
29
|
*/
|
|
22
30
|
export declare const LEAVE_FILL: string;
|
|
31
|
+
/**
|
|
32
|
+
* The open button when the app also has a Quick Jump. A row holds exactly ONE
|
|
33
|
+
* solid brand fill and it belongs on the submit — the one element in a
|
|
34
|
+
* field-shaped control that has to announce "this acts on what you typed".
|
|
35
|
+
* Sharing the fill made the two read as a single four-part blob.
|
|
36
|
+
*
|
|
37
|
+
* The button keeps its primacy through position and its self-describing label,
|
|
38
|
+
* not through being the loudest thing in the row. The branch is decided by the
|
|
39
|
+
* app's data, never by the input's value: the hierarchy must not flicker while
|
|
40
|
+
* the user types.
|
|
41
|
+
*
|
|
42
|
+
* The border is not decoration. `--secondary` and the picker's `--muted` sit
|
|
43
|
+
* 0.015 apart in lightness, so without it the button and the shell's first
|
|
44
|
+
* segment read as one continuous panel straight across the gap — the same
|
|
45
|
+
* failure again, in grey.
|
|
46
|
+
*/
|
|
47
|
+
export declare const QUIET_FILL = "border border-border bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground";
|
|
23
48
|
/**
|
|
24
49
|
* Quick Jump: `[ destination ▾ | id | Jump ]`, one control sitting next to the
|
|
25
50
|
* app's own open button. The picker only names and swaps the destination; Jump
|
|
@@ -5,21 +5,36 @@ import { useUrlSyncedState } from "../../hooks/useUrlSyncedState.js";
|
|
|
5
5
|
import { groupQuickJumps, quickJumpSlug, buildQuickJumpUrl, quickJumpShortTitle, quickJumpFieldName } from "../../utils/quickJump.js";
|
|
6
6
|
import { cn } from "../../../../lib/utils.js";
|
|
7
7
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuLabel, DropdownMenuRadioItem } from "../../../../ui/dropdown-menu.js";
|
|
8
|
+
function hasQuickJumps(app) {
|
|
9
|
+
return groupQuickJumps(app).some((group) => group.jumps.length > 0);
|
|
10
|
+
}
|
|
11
|
+
const SEGMENT = "inline-flex items-center gap-1.5 px-3 text-sm whitespace-nowrap";
|
|
12
|
+
const SEGMENT_FOCUS = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring";
|
|
8
13
|
const PICKER = [
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
SEGMENT,
|
|
15
|
+
// `calc(var(--radius)_-_1px)` is the shell's radius less its own border, so the
|
|
16
|
+
// inner corners are concentric with the outer ones. Written out rather than
|
|
17
|
+
// interpolated from a constant: Tailwind reads these class names out of the
|
|
18
|
+
// source text, and a template hole is not a class name.
|
|
19
|
+
"w-full min-h-10 shrink-0 rounded-t-[calc(var(--radius)_-_1px)] border-b border-border bg-muted text-left font-semibold",
|
|
20
|
+
"sm:w-auto sm:min-h-8 sm:max-w-[min(20rem,100%)] sm:rounded-t-none sm:rounded-l-[calc(var(--radius)_-_1px)] sm:border-b-0"
|
|
12
21
|
].join(" ");
|
|
13
|
-
const SEGMENT_FOCUS = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring";
|
|
14
22
|
const JUMP = [
|
|
15
|
-
|
|
16
|
-
"
|
|
23
|
+
SEGMENT,
|
|
24
|
+
"min-h-8 shrink-0 rounded-br-[calc(var(--radius)_-_1px)] border-l border-border font-bold sm:rounded-br-none sm:rounded-r-[calc(var(--radius)_-_1px)]",
|
|
17
25
|
SEGMENT_FOCUS
|
|
18
26
|
].join(" ");
|
|
19
27
|
const LEAP = "origin-bottom transition-[background-color,translate,scale] duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]";
|
|
20
28
|
const LEAVE_FILL = [
|
|
21
|
-
"bg-[color-mix(in_oklab,var(--color-primary)
|
|
22
|
-
"hover:bg-[color-mix(in_oklab,var(--color-primary)
|
|
29
|
+
"bg-[color-mix(in_oklab,var(--color-primary)_85%,black)] text-primary-foreground",
|
|
30
|
+
"hover:bg-[color-mix(in_oklab,var(--color-primary)_75%,black)]"
|
|
31
|
+
].join(" ");
|
|
32
|
+
const QUIET_FILL = "border border-border bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground";
|
|
33
|
+
const JUMP_FILL = [
|
|
34
|
+
LEAVE_FILL,
|
|
35
|
+
"active:bg-[color-mix(in_oklab,var(--color-primary)_68%,black)]",
|
|
36
|
+
"active:shadow-[inset_0_2px_3px_rgb(0_0_0/0.22)]",
|
|
37
|
+
"transition-[background-color,box-shadow] duration-150 ease-out"
|
|
23
38
|
].join(" ");
|
|
24
39
|
function QuickJumpBar({ app }) {
|
|
25
40
|
const identities = React__default.useMemo(() => groupQuickJumps(app), [app]);
|
|
@@ -34,6 +49,7 @@ function QuickJumpBar({ app }) {
|
|
|
34
49
|
});
|
|
35
50
|
const [value, setValue] = React__default.useState("");
|
|
36
51
|
const inputRef = React__default.useRef(null);
|
|
52
|
+
const hintId = React__default.useId();
|
|
37
53
|
React__default.useEffect(() => setValue(""), [jumps]);
|
|
38
54
|
React__default.useEffect(() => {
|
|
39
55
|
if (slug && !jumps.some((jump) => quickJumpSlug(jump) === slug)) setSlug("");
|
|
@@ -43,151 +59,176 @@ function QuickJumpBar({ app }) {
|
|
|
43
59
|
if (!selected) return null;
|
|
44
60
|
const trimmed = value.trim();
|
|
45
61
|
const href = (trimmed ? buildQuickJumpUrl(selected, trimmed, app.appUrl) : null) ?? void 0;
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
{
|
|
63
|
-
title: `${selected.title} — choose a destination`,
|
|
64
|
-
className: cn(
|
|
65
|
-
PICKER,
|
|
66
|
-
SEGMENT_FOCUS,
|
|
67
|
-
"cursor-pointer transition-colors hover:bg-accent"
|
|
68
|
-
),
|
|
69
|
-
children: [
|
|
70
|
-
/* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children: quickJumpShortTitle(selected) }),
|
|
71
|
-
/* @__PURE__ */ jsx(ChevronDown, { className: "size-4 shrink-0 text-muted-foreground" })
|
|
72
|
-
]
|
|
73
|
-
}
|
|
74
|
-
),
|
|
75
|
-
/* @__PURE__ */ jsx(
|
|
76
|
-
DropdownMenuContent,
|
|
77
|
-
{
|
|
78
|
-
align: "start",
|
|
79
|
-
className: "max-w-[min(90vw,34rem)]",
|
|
80
|
-
children: /* @__PURE__ */ jsx(
|
|
81
|
-
DropdownMenuRadioGroup,
|
|
82
|
-
{
|
|
83
|
-
value: quickJumpSlug(selected),
|
|
84
|
-
onValueChange: setSlug,
|
|
85
|
-
children: identities.map((group) => /* @__PURE__ */ jsxs(React__default.Fragment, { children: [
|
|
86
|
-
/* @__PURE__ */ jsx(DropdownMenuLabel, { className: "text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase", children: group.identity }),
|
|
87
|
-
group.jumps.map((jump) => /* @__PURE__ */ jsx(
|
|
88
|
-
DropdownMenuRadioItem,
|
|
89
|
-
{
|
|
90
|
-
value: quickJumpSlug(jump),
|
|
91
|
-
className: "whitespace-nowrap",
|
|
92
|
-
children: jump.title
|
|
93
|
-
},
|
|
94
|
-
quickJumpSlug(jump)
|
|
95
|
-
))
|
|
96
|
-
] }, group.identity))
|
|
97
|
-
}
|
|
98
|
-
)
|
|
99
|
-
}
|
|
100
|
-
)
|
|
101
|
-
] }) : /* @__PURE__ */ jsx("span", { title: selected.title, className: cn(PICKER, "truncate"), children: quickJumpShortTitle(selected) }),
|
|
62
|
+
const widest = jumps.map(quickJumpShortTitle).reduce(
|
|
63
|
+
(longest, title) => title.length > longest.length ? title : longest
|
|
64
|
+
);
|
|
65
|
+
return /* @__PURE__ */ jsxs(
|
|
66
|
+
"div",
|
|
67
|
+
{
|
|
68
|
+
className: cn(
|
|
69
|
+
"group flex w-full max-w-full flex-wrap items-stretch rounded-lg border border-input bg-card shadow-[inset_0_1px_2px_rgb(0_0_0/0.09)]",
|
|
70
|
+
// Any segment, not just the field: the picker and Jump are focusable
|
|
71
|
+
// too, and a shell that stays unlit for two of its three ends reads as
|
|
72
|
+
// if tabbing had left the control.
|
|
73
|
+
"has-[:focus-visible]:border-ring has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/25",
|
|
74
|
+
"sm:w-auto"
|
|
75
|
+
),
|
|
76
|
+
children: [
|
|
77
|
+
jumps.length > 1 ? /* @__PURE__ */ jsxs(DropdownMenu, { children: [
|
|
102
78
|
/* @__PURE__ */ jsx(
|
|
103
|
-
|
|
79
|
+
DropdownMenuTrigger,
|
|
104
80
|
{
|
|
105
|
-
|
|
106
|
-
type: "text",
|
|
107
|
-
size: fieldChars,
|
|
108
|
-
name: quickJumpFieldName(selected.identity),
|
|
109
|
-
autoComplete: "on",
|
|
110
|
-
"aria-label": selected.identity,
|
|
111
|
-
placeholder: `Enter ${selected.identity}`,
|
|
112
|
-
value,
|
|
113
|
-
onChange: (e) => setValue(e.target.value),
|
|
114
|
-
onFocus: (e) => e.currentTarget.select(),
|
|
115
|
-
onKeyDown: (e) => {
|
|
116
|
-
if (e.key !== "Enter" || !href) return;
|
|
117
|
-
window.open(href, "_blank", "noopener,noreferrer");
|
|
118
|
-
},
|
|
81
|
+
title: `${selected.title} — choose a destination`,
|
|
119
82
|
className: cn(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
"
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
83
|
+
PICKER,
|
|
84
|
+
SEGMENT_FOCUS,
|
|
85
|
+
"cursor-pointer transition-colors hover:bg-accent"
|
|
86
|
+
),
|
|
87
|
+
children: /* @__PURE__ */ jsxs("span", { className: "grid min-w-0 max-w-[296px]", children: [
|
|
88
|
+
/* @__PURE__ */ jsxs("span", { className: "flex min-w-0 items-center gap-1.5 [grid-area:1/1]", children: [
|
|
89
|
+
/* @__PURE__ */ jsx("span", { className: "truncate", children: quickJumpShortTitle(selected) }),
|
|
90
|
+
/* @__PURE__ */ jsx(ChevronDown, { className: "size-4 shrink-0 text-foreground/70" })
|
|
91
|
+
] }),
|
|
92
|
+
widest !== quickJumpShortTitle(selected) && /* @__PURE__ */ jsx(
|
|
93
|
+
"span",
|
|
94
|
+
{
|
|
95
|
+
"aria-hidden": true,
|
|
96
|
+
className: "invisible h-0 pr-[22px] whitespace-nowrap [grid-area:1/1]",
|
|
97
|
+
children: widest
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
] })
|
|
129
101
|
}
|
|
130
102
|
),
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
{
|
|
134
|
-
href,
|
|
135
|
-
target: "_blank",
|
|
136
|
-
rel: "noopener noreferrer",
|
|
137
|
-
title: `Jump to ${selected.title}`,
|
|
138
|
-
className: cn(JUMP, LEAP, LEAVE_FILL, "animate-button-pop"),
|
|
139
|
-
children: [
|
|
140
|
-
"Jump",
|
|
141
|
-
/* @__PURE__ */ jsx(ExternalLink, { className: "size-3.5 shrink-0" })
|
|
142
|
-
]
|
|
143
|
-
}
|
|
144
|
-
) : /* @__PURE__ */ jsxs(
|
|
145
|
-
"button",
|
|
103
|
+
/* @__PURE__ */ jsx(
|
|
104
|
+
DropdownMenuContent,
|
|
146
105
|
{
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
106
|
+
align: "start",
|
|
107
|
+
sideOffset: 6,
|
|
108
|
+
className: "max-w-[min(90vw,34rem)] min-w-[var(--radix-dropdown-menu-trigger-width)]",
|
|
109
|
+
children: /* @__PURE__ */ jsx(
|
|
110
|
+
DropdownMenuRadioGroup,
|
|
111
|
+
{
|
|
112
|
+
value: quickJumpSlug(selected),
|
|
113
|
+
onValueChange: setSlug,
|
|
114
|
+
children: identities.map((group) => /* @__PURE__ */ jsxs(React__default.Fragment, { children: [
|
|
115
|
+
/* @__PURE__ */ jsx(DropdownMenuLabel, { className: "px-2 py-1 text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase", children: group.identity }),
|
|
116
|
+
group.jumps.map((jump) => /* @__PURE__ */ jsx(
|
|
117
|
+
DropdownMenuRadioItem,
|
|
118
|
+
{
|
|
119
|
+
value: quickJumpSlug(jump),
|
|
120
|
+
className: "rounded-[8px] pl-7 whitespace-nowrap",
|
|
121
|
+
children: jump.title
|
|
122
|
+
},
|
|
123
|
+
quickJumpSlug(jump)
|
|
124
|
+
))
|
|
125
|
+
] }, group.identity))
|
|
126
|
+
}
|
|
127
|
+
)
|
|
161
128
|
}
|
|
162
129
|
)
|
|
163
|
-
]
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
130
|
+
] }) : /* @__PURE__ */ jsx("span", { title: selected.title, className: cn(PICKER, "truncate"), children: quickJumpShortTitle(selected) }),
|
|
131
|
+
/* @__PURE__ */ jsxs(
|
|
132
|
+
"div",
|
|
133
|
+
{
|
|
134
|
+
className: cn(
|
|
135
|
+
"relative flex min-h-8 w-[170px] min-w-[120px] flex-[1_1_170px] border-border",
|
|
136
|
+
// Grows into the row's leftovers below `sm`, where it shares a line
|
|
137
|
+
// with Jump. It must NOT grow from `sm` up: the shell is auto-width
|
|
138
|
+
// there, so growing fills nothing but does hand the field the
|
|
139
|
+
// picker's clamped-away max-content overflow.
|
|
140
|
+
"sm:flex-[0_1_170px] sm:border-l"
|
|
141
|
+
),
|
|
142
|
+
children: [
|
|
143
|
+
/* @__PURE__ */ jsx(
|
|
144
|
+
"input",
|
|
145
|
+
{
|
|
146
|
+
ref: inputRef,
|
|
147
|
+
type: "text",
|
|
148
|
+
name: quickJumpFieldName(selected.identity),
|
|
149
|
+
autoComplete: "on",
|
|
150
|
+
"aria-label": selected.identity,
|
|
151
|
+
placeholder: selected.identity,
|
|
152
|
+
value,
|
|
153
|
+
onChange: (e) => setValue(e.target.value),
|
|
154
|
+
onFocus: (e) => e.currentTarget.select(),
|
|
155
|
+
onKeyDown: (e) => {
|
|
156
|
+
if (e.key !== "Enter" || !href) return;
|
|
157
|
+
window.open(href, "_blank", "noopener,noreferrer");
|
|
158
|
+
},
|
|
159
|
+
className: cn(
|
|
160
|
+
"absolute inset-0 h-full w-full bg-transparent px-3 font-mono text-sm font-medium outline-none placeholder:font-sans placeholder:text-muted-foreground",
|
|
161
|
+
// Hovering the dormant Jump lights up the field it is waiting on.
|
|
162
|
+
"transition-[box-shadow,background-color] duration-150",
|
|
163
|
+
"group-has-[.qj-dormant:hover]:bg-ring/10 group-has-[.qj-dormant:hover]:shadow-[inset_0_0_0_2px_var(--color-ring)]",
|
|
164
|
+
"group-has-[.qj-dormant:hover]:placeholder:text-foreground"
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
),
|
|
168
|
+
/* @__PURE__ */ jsxs(
|
|
169
|
+
"p",
|
|
170
|
+
{
|
|
171
|
+
id: hintId,
|
|
172
|
+
className: cn(
|
|
173
|
+
"pointer-events-none absolute top-[calc(100%+0.5rem)] left-0 z-30 m-0 flex items-center rounded-md bg-foreground px-2 py-1 text-xs font-semibold whitespace-nowrap text-background",
|
|
174
|
+
"before:absolute before:-top-[3px] before:left-[0.9rem] before:size-2 before:rotate-45 before:rounded-[1px] before:bg-foreground",
|
|
175
|
+
// `translate`, not `transform`: v4 utilities set the separate
|
|
176
|
+
// `translate` property, which a transform transition never sees.
|
|
177
|
+
"-translate-y-[3px] opacity-0 transition-[opacity,translate] duration-150",
|
|
178
|
+
"group-has-[.qj-dormant:hover]:translate-y-0 group-has-[.qj-dormant:hover]:opacity-100",
|
|
179
|
+
"group-has-[.qj-dormant:focus-visible]:translate-y-0 group-has-[.qj-dormant:focus-visible]:opacity-100"
|
|
180
|
+
),
|
|
181
|
+
children: [
|
|
182
|
+
"Type a ",
|
|
183
|
+
selected.identity,
|
|
184
|
+
" here first"
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
]
|
|
189
|
+
}
|
|
178
190
|
),
|
|
179
|
-
|
|
180
|
-
"
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
191
|
+
href ? /* @__PURE__ */ jsxs(
|
|
192
|
+
"a",
|
|
193
|
+
{
|
|
194
|
+
href,
|
|
195
|
+
target: "_blank",
|
|
196
|
+
rel: "noopener noreferrer",
|
|
197
|
+
title: `Jump to ${selected.title}`,
|
|
198
|
+
className: cn(JUMP, JUMP_FILL),
|
|
199
|
+
children: [
|
|
200
|
+
"Jump",
|
|
201
|
+
/* @__PURE__ */ jsx(ExternalLink, { className: "size-3.5 shrink-0" })
|
|
202
|
+
]
|
|
203
|
+
}
|
|
204
|
+
) : /* @__PURE__ */ jsxs(
|
|
205
|
+
"button",
|
|
206
|
+
{
|
|
207
|
+
type: "button",
|
|
208
|
+
"aria-describedby": hintId,
|
|
209
|
+
onClick: () => {
|
|
210
|
+
var _a;
|
|
211
|
+
return (_a = inputRef.current) == null ? void 0 : _a.focus();
|
|
212
|
+
},
|
|
213
|
+
className: cn(
|
|
214
|
+
JUMP,
|
|
215
|
+
"qj-dormant cursor-text bg-primary/12 text-muted-foreground"
|
|
216
|
+
),
|
|
217
|
+
children: [
|
|
218
|
+
"Jump",
|
|
219
|
+
/* @__PURE__ */ jsx(ExternalLink, { className: "size-3.5 shrink-0" })
|
|
220
|
+
]
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
]
|
|
224
|
+
}
|
|
225
|
+
);
|
|
187
226
|
}
|
|
188
227
|
export {
|
|
189
228
|
LEAP,
|
|
190
229
|
LEAVE_FILL,
|
|
191
|
-
|
|
230
|
+
QUIET_FILL,
|
|
231
|
+
QuickJumpBar,
|
|
232
|
+
hasQuickJumps
|
|
192
233
|
};
|
|
193
234
|
//# sourceMappingURL=QuickJumpBar.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"QuickJumpBar.js","sources":["../../../../../../src/modules/appCatalog/ui/components/QuickJumpBar.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ChevronDown, ExternalLink } from 'lucide-react'\nimport React from 'react'\n\nimport { useUrlSyncedState } from '~/modules/appCatalog/hooks/useUrlSyncedState'\nimport {\n buildQuickJumpUrl,\n groupQuickJumps,\n quickJumpFieldName,\n quickJumpShortTitle,\n quickJumpSlug,\n} from '~/modules/appCatalog/utils/quickJump'\nimport { cn } from '~/lib/utils'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from '~/ui/dropdown-menu'\n\n/**\n * The destination picker. Fixed width on a desktop row, so swapping destination\n * cannot slide the field and Jump sideways; its own full-width line below `sm`,\n * where the three segments together do not fit in a phone's panel at all. It\n * carries the short title — the full one is in the menu, where you are choosing\n * between systems. Longer shorts still ellipsis here.\n */\nconst PICKER = [\n 'inline-flex w-full shrink-0 items-center gap-1.5 rounded-t-lg border-b border-border bg-muted px-2',\n 'text-left text-[0.8125rem] font-semibold whitespace-nowrap',\n 'sm:w-44 sm:rounded-t-none sm:rounded-l-lg sm:border-b-0',\n].join(' ')\n\n/** One focus ring per focusable segment, or tabbing the row never says where\n you are: the shell's ring belongs to the field (below), these to the ends. */\nconst SEGMENT_FOCUS =\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring'\n\n/** Shared by Jump and by the app's own \"Go to <host>\" button. */\nconst JUMP = [\n 'inline-flex shrink-0 items-center gap-1.5 rounded-br-lg border-l border-border px-3',\n 'text-[0.8125rem] font-bold whitespace-nowrap sm:rounded-br-none sm:rounded-r-lg',\n SEGMENT_FOCUS,\n].join(' ')\n\n/**\n * The leap, as env-hopper does it: the button lifts onto its toes on `:hover`\n * and crouches on `:active`. Deliberately NOT an on-click animation — these\n * links open a new tab, which takes focus the same instant, so a keyframe fired\n * on click runs in a backgrounded tab and the user only sees it on the way back.\n *\n * The transition has to name `translate` and `scale`: in Tailwind v4 those\n * utilities set those separate properties, which a `transform` transition never\n * sees.\n */\nexport const LEAP =\n 'origin-bottom transition-[background-color,translate,scale] duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]'\n\n/**\n * The brand fill for the two buttons that leave for the app. Darkened, because\n * `--primary` under `--primary-foreground` measures 3.8:1 — below WCAG AA, and\n * the conventional `bg-primary/90` hover made it worse by fading the fill\n * toward the card instead of darkening it.\n *\n * A token-level fix would be better and is a brand decision, not this\n * component's: every filled button in the app inherits the same ratio.\n */\nexport const LEAVE_FILL = [\n 'bg-[color-mix(in_oklab,var(--color-primary)_90%,black)] text-primary-foreground',\n 'hover:bg-[color-mix(in_oklab,var(--color-primary)_80%,black)]',\n].join(' ')\n\n/**\n * Quick Jump: `[ destination ▾ | id | Jump ]`, one control sitting next to the\n * app's own open button. The picker only names and swaps the destination; Jump\n * is the sole thing that navigates. Renders nothing for an app with no jumps.\n */\nexport function QuickJumpBar({ app }: { app: Resource }) {\n const identities = React.useMemo(() => groupQuickJumps(app), [app])\n const jumps = React.useMemo(\n () => identities.flatMap((group) => group.jumps),\n [identities],\n )\n // The chosen destination lives in the url (`?qj=tracker.view-case`), so a\n // \"use this one\" link is shareable. Empty = the app's first jump.\n const [slug, setSlug] = useUrlSyncedState<string>({\n key: 'qj',\n defaultValue: '',\n encode: (value) => value || undefined,\n })\n const [value, setValue] = React.useState('')\n const inputRef = React.useRef<HTMLInputElement>(null)\n\n // A typed id belongs to the app it was typed on.\n React.useEffect(() => setValue(''), [jumps])\n\n // A `?qj=` naming another app's jump would silently resolve to this app's\n // first one, leaving the url claiming something untrue.\n React.useEffect(() => {\n if (slug && !jumps.some((jump) => quickJumpSlug(jump) === slug)) setSlug('')\n }, [jumps, slug, setSlug])\n\n if (jumps.length === 0) return null\n\n const selected =\n jumps.find((jump) => quickJumpSlug(jump) === slug) ?? jumps[0]\n if (!selected) return null\n\n const trimmed = value.trim()\n // Never null in practice: groupQuickJumps already dropped the jumps this app\n // cannot build a url for.\n const href =\n (trimmed ? buildQuickJumpUrl(selected, trimmed, app.appUrl) : null) ??\n undefined\n\n // Sized once from the longest identifier this app asks for, not per\n // selection: the field must not resize when the destination changes. `size`\n // is in characters of the input's own font, so it survives a type-scale change.\n const fieldChars =\n Math.max(...jumps.map((jump) => jump.identity.length), 8) + 8\n\n return (\n <div className=\"relative flex w-full sm:w-auto\">\n <div\n className={cn(\n 'group peer flex w-full flex-wrap items-stretch rounded-lg border border-input bg-card shadow-[inset_0_1px_2px_rgb(0_0_0/0.09)]',\n // The ring is the FIELD's, not the shell's: the picker and Jump are\n // focusable too and ringing the shell for all three says nothing.\n 'has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/25',\n 'sm:w-auto sm:flex-nowrap',\n )}\n >\n {jumps.length > 1 ? (\n <DropdownMenu>\n <DropdownMenuTrigger\n title={`${selected.title} — choose a destination`}\n className={cn(\n PICKER,\n SEGMENT_FOCUS,\n 'cursor-pointer transition-colors hover:bg-accent',\n )}\n >\n {/* Caret immediately after the label, not shoved to the far edge:\n out there it reads as the field's boundary and leaves the\n picker looking like a wide empty select. */}\n <span className=\"min-w-0 truncate\">\n {quickJumpShortTitle(selected)}\n </span>\n <ChevronDown className=\"size-4 shrink-0 text-muted-foreground\" />\n </DropdownMenuTrigger>\n {/* Portalled and sized to its content: the trigger ellipsises, the\n menu never does. */}\n <DropdownMenuContent\n align=\"start\"\n className=\"max-w-[min(90vw,34rem)]\"\n >\n <DropdownMenuRadioGroup\n value={quickJumpSlug(selected)}\n onValueChange={setSlug}\n >\n {identities.map((group) => (\n <React.Fragment key={group.identity}>\n <DropdownMenuLabel className=\"text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase\">\n {group.identity}\n </DropdownMenuLabel>\n {group.jumps.map((jump) => (\n <DropdownMenuRadioItem\n key={quickJumpSlug(jump)}\n value={quickJumpSlug(jump)}\n className=\"whitespace-nowrap\"\n >\n {jump.title}\n </DropdownMenuRadioItem>\n ))}\n </React.Fragment>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n ) : (\n <span title={selected.title} className={cn(PICKER, 'truncate')}>\n {quickJumpShortTitle(selected)}\n </span>\n )}\n\n <input\n ref={inputRef}\n type=\"text\"\n size={fieldChars}\n // Namespaced per identity: the browser remembers values typed for this\n // identifier and offers them on every app that asks for it.\n name={quickJumpFieldName(selected.identity)}\n autoComplete=\"on\"\n aria-label={selected.identity}\n placeholder={`Enter ${selected.identity}`}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n // Selecting on focus rather than on click, or a second click inside\n // could never place a caret.\n onFocus={(e) => e.currentTarget.select()}\n onKeyDown={(e) => {\n if (e.key !== 'Enter' || !href) return\n window.open(href, '_blank', 'noopener,noreferrer')\n }}\n className={cn(\n // Grows into the row's leftovers below `sm`, where it shares a line\n // with Jump; its own fixed `size` from there up.\n 'min-w-0 flex-1 border-border bg-transparent px-2.5 py-1.5 font-mono text-[0.8125rem] font-medium outline-none placeholder:font-sans placeholder:text-muted-foreground',\n 'sm:flex-none sm:border-l',\n // Hovering the dormant Jump lights up the field it is waiting on.\n 'transition-[box-shadow,background-color] duration-150',\n 'group-has-[.qj-dormant:hover]:bg-ring/10 group-has-[.qj-dormant:hover]:shadow-[inset_0_0_0_2px_var(--color-ring)]',\n 'group-has-[.qj-dormant:hover]:placeholder:text-foreground',\n )}\n />\n\n {/* A real <button> while dormant. An <a> without href is not focusable,\n so the whole \"press it and it tells you to type\" behaviour — and the\n hint's :focus-visible branch — would have been mouse-only. */}\n {href ? (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n // Title, not the url: the browser already puts the url in the\n // status bar, and some of these are 250 characters long. It doubles\n // as the full text when the picker has ellipsised it.\n title={`Jump to ${selected.title}`}\n className={cn(JUMP, LEAP, LEAVE_FILL, 'animate-button-pop')}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </a>\n ) : (\n <button\n type=\"button\"\n title={`Enter a ${selected.identity} first`}\n onClick={() => inputRef.current?.focus()}\n // Tinted toward the action rather than greyed: on `bg-muted` it was\n // the picker's twin, so the row read as two dropdowns around a\n // field and nothing in it looked raised.\n className={cn(\n JUMP,\n 'qj-dormant cursor-text bg-primary/10 text-muted-foreground',\n )}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </button>\n )}\n </div>\n\n {/* Hovering a button that does nothing is someone asking why. The answer\n points at the FIELD, not at the button under the cursor: the tail\n lands just inside the field's left edge (`left-44` = the picker's\n `w-44`; below `sm` the field starts the line) and the field lights up\n at the same time. */}\n <p\n aria-hidden\n className={cn(\n 'pointer-events-none absolute top-[calc(100%+0.5rem)] left-0 z-30 m-0 flex items-center rounded-md bg-foreground px-2 py-1 text-xs font-semibold whitespace-nowrap text-background sm:left-44',\n 'before:absolute before:-top-[3px] before:left-[0.9rem] before:size-2 before:rotate-45 before:rounded-[1px] before:bg-foreground',\n // `translate`, not `transform`: v4 utilities set the separate\n // `translate` property, which a transform transition never sees.\n '-translate-y-[3px] opacity-0 transition-[opacity,translate] duration-150',\n 'peer-has-[.qj-dormant:hover]:translate-y-0 peer-has-[.qj-dormant:hover]:opacity-100',\n 'peer-has-[.qj-dormant:focus-visible]:translate-y-0 peer-has-[.qj-dormant:focus-visible]:opacity-100',\n )}\n >\n Type a {selected.identity} here first\n </p>\n </div>\n )\n}\n"],"names":["React","value"],"mappings":";;;;;;;AA6BA,MAAM,SAAS;AAAA,EACb;AAAA,EACA;AAAA,EACA;AACF,EAAE,KAAK,GAAG;AAIV,MAAM,gBACJ;AAGF,MAAM,OAAO;AAAA,EACX;AAAA,EACA;AAAA,EACA;AACF,EAAE,KAAK,GAAG;AAYH,MAAM,OACX;AAWK,MAAM,aAAa;AAAA,EACxB;AAAA,EACA;AACF,EAAE,KAAK,GAAG;AAOH,SAAS,aAAa,EAAE,OAA0B;AACvD,QAAM,aAAaA,eAAM,QAAQ,MAAM,gBAAgB,GAAG,GAAG,CAAC,GAAG,CAAC;AAClE,QAAM,QAAQA,eAAM;AAAA,IAClB,MAAM,WAAW,QAAQ,CAAC,UAAU,MAAM,KAAK;AAAA,IAC/C,CAAC,UAAU;AAAA,EAAA;AAIb,QAAM,CAAC,MAAM,OAAO,IAAI,kBAA0B;AAAA,IAChD,KAAK;AAAA,IACL,cAAc;AAAA,IACd,QAAQ,CAACC,WAAUA,UAAS;AAAA,EAAA,CAC7B;AACD,QAAM,CAAC,OAAO,QAAQ,IAAID,eAAM,SAAS,EAAE;AAC3C,QAAM,WAAWA,eAAM,OAAyB,IAAI;AAGpDA,iBAAM,UAAU,MAAM,SAAS,EAAE,GAAG,CAAC,KAAK,CAAC;AAI3CA,iBAAM,UAAU,MAAM;AACpB,QAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,SAAS,cAAc,IAAI,MAAM,IAAI,EAAG,SAAQ,EAAE;AAAA,EAC7E,GAAG,CAAC,OAAO,MAAM,OAAO,CAAC;AAEzB,MAAI,MAAM,WAAW,EAAG,QAAO;AAE/B,QAAM,WACJ,MAAM,KAAK,CAAC,SAAS,cAAc,IAAI,MAAM,IAAI,KAAK,MAAM,CAAC;AAC/D,MAAI,CAAC,SAAU,QAAO;AAEtB,QAAM,UAAU,MAAM,KAAA;AAGtB,QAAM,QACH,UAAU,kBAAkB,UAAU,SAAS,IAAI,MAAM,IAAI,SAC9D;AAKF,QAAM,aACJ,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC,SAAS,KAAK,SAAS,MAAM,GAAG,CAAC,IAAI;AAE9D,SACE,qBAAC,OAAA,EAAI,WAAU,kCACb,UAAA;AAAA,IAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,WAAW;AAAA,UACT;AAAA;AAAA;AAAA,UAGA;AAAA,UACA;AAAA,QAAA;AAAA,QAGD,UAAA;AAAA,UAAA,MAAM,SAAS,IACd,qBAAC,cAAA,EACC,UAAA;AAAA,YAAA;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,OAAO,GAAG,SAAS,KAAK;AAAA,gBACxB,WAAW;AAAA,kBACT;AAAA,kBACA;AAAA,kBACA;AAAA,gBAAA;AAAA,gBAMF,UAAA;AAAA,kBAAA,oBAAC,QAAA,EAAK,WAAU,oBACb,UAAA,oBAAoB,QAAQ,GAC/B;AAAA,kBACA,oBAAC,aAAA,EAAY,WAAU,wCAAA,CAAwC;AAAA,gBAAA;AAAA,cAAA;AAAA,YAAA;AAAA,YAIjE;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,OAAM;AAAA,gBACN,WAAU;AAAA,gBAEV,UAAA;AAAA,kBAAC;AAAA,kBAAA;AAAA,oBACC,OAAO,cAAc,QAAQ;AAAA,oBAC7B,eAAe;AAAA,oBAEd,qBAAW,IAAI,CAAC,UACf,qBAACA,eAAM,UAAN,EACC,UAAA;AAAA,sBAAA,oBAAC,mBAAA,EAAkB,WAAU,gFAC1B,UAAA,MAAM,UACT;AAAA,sBACC,MAAM,MAAM,IAAI,CAAC,SAChB;AAAA,wBAAC;AAAA,wBAAA;AAAA,0BAEC,OAAO,cAAc,IAAI;AAAA,0BACzB,WAAU;AAAA,0BAET,UAAA,KAAK;AAAA,wBAAA;AAAA,wBAJD,cAAc,IAAI;AAAA,sBAAA,CAM1B;AAAA,oBAAA,EAAA,GAZkB,MAAM,QAa3B,CACD;AAAA,kBAAA;AAAA,gBAAA;AAAA,cACH;AAAA,YAAA;AAAA,UACF,EAAA,CACF,IAEA,oBAAC,QAAA,EAAK,OAAO,SAAS,OAAO,WAAW,GAAG,QAAQ,UAAU,GAC1D,UAAA,oBAAoB,QAAQ,GAC/B;AAAA,UAGF;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,KAAK;AAAA,cACL,MAAK;AAAA,cACL,MAAM;AAAA,cAGN,MAAM,mBAAmB,SAAS,QAAQ;AAAA,cAC1C,cAAa;AAAA,cACb,cAAY,SAAS;AAAA,cACrB,aAAa,SAAS,SAAS,QAAQ;AAAA,cACvC;AAAA,cACA,UAAU,CAAC,MAAM,SAAS,EAAE,OAAO,KAAK;AAAA,cAGxC,SAAS,CAAC,MAAM,EAAE,cAAc,OAAA;AAAA,cAChC,WAAW,CAAC,MAAM;AAChB,oBAAI,EAAE,QAAQ,WAAW,CAAC,KAAM;AAChC,uBAAO,KAAK,MAAM,UAAU,qBAAqB;AAAA,cACnD;AAAA,cACA,WAAW;AAAA;AAAA;AAAA,gBAGT;AAAA,gBACA;AAAA;AAAA,gBAEA;AAAA,gBACA;AAAA,gBACA;AAAA,cAAA;AAAA,YACF;AAAA,UAAA;AAAA,UAMD,OACC;AAAA,YAAC;AAAA,YAAA;AAAA,cACC;AAAA,cACA,QAAO;AAAA,cACP,KAAI;AAAA,cAIJ,OAAO,WAAW,SAAS,KAAK;AAAA,cAChC,WAAW,GAAG,MAAM,MAAM,YAAY,oBAAoB;AAAA,cAC3D,UAAA;AAAA,gBAAA;AAAA,gBAEC,oBAAC,cAAA,EAAa,WAAU,oBAAA,CAAoB;AAAA,cAAA;AAAA,YAAA;AAAA,UAAA,IAG9C;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAK;AAAA,cACL,OAAO,WAAW,SAAS,QAAQ;AAAA,cACnC,SAAS,MAAA;;AAAM,sCAAS,YAAT,mBAAkB;AAAA;AAAA,cAIjC,WAAW;AAAA,gBACT;AAAA,gBACA;AAAA,cAAA;AAAA,cAEH,UAAA;AAAA,gBAAA;AAAA,gBAEC,oBAAC,cAAA,EAAa,WAAU,oBAAA,CAAoB;AAAA,cAAA;AAAA,YAAA;AAAA,UAAA;AAAA,QAC9C;AAAA,MAAA;AAAA,IAAA;AAAA,IASJ;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,eAAW;AAAA,QACX,WAAW;AAAA,UACT;AAAA,UACA;AAAA;AAAA;AAAA,UAGA;AAAA,UACA;AAAA,UACA;AAAA,QAAA;AAAA,QAEH,UAAA;AAAA,UAAA;AAAA,UACS,SAAS;AAAA,UAAS;AAAA,QAAA;AAAA,MAAA;AAAA,IAAA;AAAA,EAC5B,GACF;AAEJ;"}
|
|
1
|
+
{"version":3,"file":"QuickJumpBar.js","sources":["../../../../../../src/modules/appCatalog/ui/components/QuickJumpBar.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ChevronDown, ExternalLink } from 'lucide-react'\nimport React from 'react'\n\nimport { useUrlSyncedState } from '~/modules/appCatalog/hooks/useUrlSyncedState'\nimport {\n buildQuickJumpUrl,\n groupQuickJumps,\n quickJumpFieldName,\n quickJumpShortTitle,\n quickJumpSlug,\n} from '~/modules/appCatalog/utils/quickJump'\nimport { cn } from '~/lib/utils'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from '~/ui/dropdown-menu'\n\n/** Whether the open button should stand down for this app's Quick Jump. */\nexport function hasQuickJumps(app: Resource): boolean {\n return groupQuickJumps(app).some((group) => group.jumps.length > 0)\n}\n\n/**\n * One horizontal inset and one icon gap for every segment AND for the open\n * button, so the two seams either side of the field measure the same.\n *\n * Vertical padding is deliberately absent: height comes from a `min-h` on each\n * segment. Borrowing it from a sibling through `items-stretch` is what left the\n * picker a 20.5px tap target once it wrapped onto its own line, and what made the\n * row 33.5px on an app with jumps but 32px on an app without.\n */\nconst SEGMENT =\n 'inline-flex items-center gap-1.5 px-3 text-sm whitespace-nowrap'\n\n/** One focus ring per focusable segment, or tabbing the row never says where\n you are: the shell's ring belongs to the field (below), these to the ends. */\nconst SEGMENT_FOCUS =\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring'\n\n/**\n * The destination picker. Sized to the app's WIDEST short title (see the ghost\n * below), not to a constant: a constant was simultaneously 100px too narrow for\n * the long titles and three quarters empty on an app with one short one. Widest,\n * rather than current, is what keeps the field and Jump from sliding sideways\n * when the destination changes — the only thing the constant was protecting.\n *\n * Its own full-width line below `sm`, where the three segments together do not\n * fit in a phone's panel at all. It carries the short title — the full one is in\n * the menu, where you are choosing between systems.\n */\nconst PICKER = [\n SEGMENT,\n // `calc(var(--radius)_-_1px)` is the shell's radius less its own border, so the\n // inner corners are concentric with the outer ones. Written out rather than\n // interpolated from a constant: Tailwind reads these class names out of the\n // source text, and a template hole is not a class name.\n 'w-full min-h-10 shrink-0 rounded-t-[calc(var(--radius)_-_1px)] border-b border-border bg-muted text-left font-semibold',\n 'sm:w-auto sm:min-h-8 sm:max-w-[min(20rem,100%)] sm:rounded-t-none sm:rounded-l-[calc(var(--radius)_-_1px)] sm:border-b-0',\n].join(' ')\n\nconst JUMP = [\n SEGMENT,\n 'min-h-8 shrink-0 rounded-br-[calc(var(--radius)_-_1px)] border-l border-border font-bold sm:rounded-br-none sm:rounded-r-[calc(var(--radius)_-_1px)]',\n SEGMENT_FOCUS,\n].join(' ')\n\n/**\n * The leap, as a free-standing button does it: it lifts onto its toes on\n * `:hover` and crouches on `:active`. Deliberately NOT an on-click animation —\n * these links open a new tab, which takes focus the same instant, so a keyframe\n * fired on click runs in a backgrounded tab and the user only sees it on the way\n * back.\n *\n * The transition has to name `translate` and `scale`: in Tailwind v4 those\n * utilities set those separate properties, which a `transform` transition never\n * sees.\n *\n * This belongs to the open button ALONE. Jump is welded into a bordered frame,\n * where the same lift covered the shell's top border along its whole width and\n * the crouch opened 3.5px of card above it while overhanging the bottom. A\n * segment presses inward instead — see `JUMP_FILL`.\n */\nexport const LEAP =\n 'origin-bottom transition-[background-color,translate,scale] duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]'\n\n/**\n * The brand fill for a button that leaves for the app. Darkened, because\n * `--primary` under `--primary-foreground` measures 3.8:1 — below WCAG AA. 90%\n * measured 4.56:1, which clears AA by 0.06 at a size where bold does not count\n * as large text; 85% measures 5.19:1.\n *\n * A token-level fix would be better and is a brand decision, not this\n * component's: every filled button in the app inherits the same ratio.\n */\nexport const LEAVE_FILL = [\n 'bg-[color-mix(in_oklab,var(--color-primary)_85%,black)] text-primary-foreground',\n 'hover:bg-[color-mix(in_oklab,var(--color-primary)_75%,black)]',\n].join(' ')\n\n/**\n * The open button when the app also has a Quick Jump. A row holds exactly ONE\n * solid brand fill and it belongs on the submit — the one element in a\n * field-shaped control that has to announce \"this acts on what you typed\".\n * Sharing the fill made the two read as a single four-part blob.\n *\n * The button keeps its primacy through position and its self-describing label,\n * not through being the loudest thing in the row. The branch is decided by the\n * app's data, never by the input's value: the hierarchy must not flicker while\n * the user types.\n *\n * The border is not decoration. `--secondary` and the picker's `--muted` sit\n * 0.015 apart in lightness, so without it the button and the shell's first\n * segment read as one continuous panel straight across the gap — the same\n * failure again, in grey.\n */\nexport const QUIET_FILL =\n 'border border-border bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground'\n\n/** Jump's fill: the leave fill, pressed inward rather than crouching. */\nconst JUMP_FILL = [\n LEAVE_FILL,\n 'active:bg-[color-mix(in_oklab,var(--color-primary)_68%,black)]',\n 'active:shadow-[inset_0_2px_3px_rgb(0_0_0/0.22)]',\n 'transition-[background-color,box-shadow] duration-150 ease-out',\n].join(' ')\n\n/**\n * Quick Jump: `[ destination ▾ | id | Jump ]`, one control sitting next to the\n * app's own open button. The picker only names and swaps the destination; Jump\n * is the sole thing that navigates. Renders nothing for an app with no jumps.\n */\nexport function QuickJumpBar({ app }: { app: Resource }) {\n const identities = React.useMemo(() => groupQuickJumps(app), [app])\n const jumps = React.useMemo(\n () => identities.flatMap((group) => group.jumps),\n [identities],\n )\n // The chosen destination lives in the url (`?qj=tracker.view-case`), so a\n // \"use this one\" link is shareable. Empty = the app's first jump.\n const [slug, setSlug] = useUrlSyncedState<string>({\n key: 'qj',\n defaultValue: '',\n encode: (value) => value || undefined,\n })\n const [value, setValue] = React.useState('')\n const inputRef = React.useRef<HTMLInputElement>(null)\n const hintId = React.useId()\n\n // A typed id belongs to the app it was typed on.\n React.useEffect(() => setValue(''), [jumps])\n\n // A `?qj=` naming another app's jump would silently resolve to this app's\n // first one, leaving the url claiming something untrue.\n React.useEffect(() => {\n if (slug && !jumps.some((jump) => quickJumpSlug(jump) === slug)) setSlug('')\n }, [jumps, slug, setSlug])\n\n if (jumps.length === 0) return null\n\n const selected =\n jumps.find((jump) => quickJumpSlug(jump) === slug) ?? jumps[0]\n if (!selected) return null\n\n const trimmed = value.trim()\n // Never null in practice: groupQuickJumps already dropped the jumps this app\n // cannot build a url for.\n const href =\n (trimmed ? buildQuickJumpUrl(selected, trimmed, app.appUrl) : null) ??\n undefined\n\n // What the picker is sized to. Longest by characters rather than by measured\n // text: close enough at one weight and one size, and it costs no layout pass.\n const widest = jumps\n .map(quickJumpShortTitle)\n .reduce((longest, title) =>\n title.length > longest.length ? title : longest,\n )\n\n return (\n <div\n className={cn(\n 'group flex w-full max-w-full flex-wrap items-stretch rounded-lg border border-input bg-card shadow-[inset_0_1px_2px_rgb(0_0_0/0.09)]',\n // Any segment, not just the field: the picker and Jump are focusable\n // too, and a shell that stays unlit for two of its three ends reads as\n // if tabbing had left the control.\n 'has-[:focus-visible]:border-ring has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/25',\n 'sm:w-auto',\n )}\n >\n {jumps.length > 1 ? (\n <DropdownMenu>\n <DropdownMenuTrigger\n title={`${selected.title} — choose a destination`}\n className={cn(\n PICKER,\n SEGMENT_FOCUS,\n 'cursor-pointer transition-colors hover:bg-accent',\n )}\n >\n {/* The shown label and an invisible copy of the widest one share a\n single grid cell, so the column is as wide as the widest and the\n segment cannot change width when the destination changes.\n\n The cap has to be on this box, not only on the segment: a\n `max-width` on a flex item does not clamp that item's\n max-content contribution, so the shell's own width would still\n be computed from the uncapped label and leave dead card-coloured\n space after Jump. */}\n <span className=\"grid min-w-0 max-w-[296px]\">\n {/* Caret inside the shown cell, immediately after the label — as\n a sibling of the whole box it would be stranded at the far\n edge on every destination but the widest, where it reads as\n the field's boundary and leaves the picker looking like a wide\n empty select. The ghost reserves its width instead. */}\n <span className=\"flex min-w-0 items-center gap-1.5 [grid-area:1/1]\">\n <span className=\"truncate\">\n {quickJumpShortTitle(selected)}\n </span>\n <ChevronDown className=\"size-4 shrink-0 text-foreground/70\" />\n </span>\n {/* Omitted when the widest destination is the one on screen: the\n shown cell already measures that wide, and a second copy of\n the same words is one a text query has to disambiguate. */}\n {widest !== quickJumpShortTitle(selected) && (\n <span\n aria-hidden\n // 16px caret + the 6px gap it sits behind.\n className=\"invisible h-0 pr-[22px] whitespace-nowrap [grid-area:1/1]\"\n >\n {widest}\n </span>\n )}\n </span>\n </DropdownMenuTrigger>\n {/* Portalled, never narrower than the trigger, and sized to its\n content beyond that: the trigger ellipsises, the menu never does. */}\n <DropdownMenuContent\n align=\"start\"\n sideOffset={6}\n className=\"max-w-[min(90vw,34rem)] min-w-[var(--radix-dropdown-menu-trigger-width)]\"\n >\n <DropdownMenuRadioGroup\n value={quickJumpSlug(selected)}\n onValueChange={setSlug}\n >\n {identities.map((group) => (\n <React.Fragment key={group.identity}>\n <DropdownMenuLabel className=\"px-2 py-1 text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase\">\n {group.identity}\n </DropdownMenuLabel>\n {group.jumps.map((jump) => (\n <DropdownMenuRadioItem\n key={quickJumpSlug(jump)}\n value={quickJumpSlug(jump)}\n // Concentric with the menu: 8px inside a 12px box with\n // 4px of padding.\n className=\"rounded-[8px] pl-7 whitespace-nowrap\"\n >\n {jump.title}\n </DropdownMenuRadioItem>\n ))}\n </React.Fragment>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n ) : (\n <span title={selected.title} className={cn(PICKER, 'truncate')}>\n {quickJumpShortTitle(selected)}\n </span>\n )}\n\n {/* The field owns a positioning context so the hint below can be anchored\n to it. The input is taken out of flow inside it: an `<input>` keeps an\n intrinsic 20-character width that wins inside an auto-width flex row,\n and the field's width is a design decision, not a font metric. */}\n <div\n className={cn(\n 'relative flex min-h-8 w-[170px] min-w-[120px] flex-[1_1_170px] border-border',\n // Grows into the row's leftovers below `sm`, where it shares a line\n // with Jump. It must NOT grow from `sm` up: the shell is auto-width\n // there, so growing fills nothing but does hand the field the\n // picker's clamped-away max-content overflow.\n 'sm:flex-[0_1_170px] sm:border-l',\n )}\n >\n <input\n ref={inputRef}\n type=\"text\"\n // Namespaced per identity: the browser remembers values typed for this\n // identifier and offers them on every app that asks for it.\n name={quickJumpFieldName(selected.identity)}\n autoComplete=\"on\"\n aria-label={selected.identity}\n // Bare identity, not \"Enter <identity>\": the verb costs ~40px of a\n // 170px field that the longest identifiers need.\n placeholder={selected.identity}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n // Selecting on focus rather than on click, or a second click inside\n // could never place a caret.\n onFocus={(e) => e.currentTarget.select()}\n onKeyDown={(e) => {\n if (e.key !== 'Enter' || !href) return\n window.open(href, '_blank', 'noopener,noreferrer')\n }}\n className={cn(\n 'absolute inset-0 h-full w-full bg-transparent px-3 font-mono text-sm font-medium outline-none placeholder:font-sans placeholder:text-muted-foreground',\n // Hovering the dormant Jump lights up the field it is waiting on.\n 'transition-[box-shadow,background-color] duration-150',\n 'group-has-[.qj-dormant:hover]:bg-ring/10 group-has-[.qj-dormant:hover]:shadow-[inset_0_0_0_2px_var(--color-ring)]',\n 'group-has-[.qj-dormant:hover]:placeholder:text-foreground',\n )}\n />\n\n {/* Hovering a button that does nothing is someone asking why. The answer\n points at the FIELD, not at the button under the cursor — which is\n why it hangs off the field's own box rather than off a hard-coded\n picker width that stopped being true once the picker was sized to its\n content. The field lights up at the same time.\n\n Not `aria-hidden`: it is the dormant button's description, which is\n also why that button no longer carries a `title` saying the same\n thing in different words. */}\n <p\n id={hintId}\n className={cn(\n 'pointer-events-none absolute top-[calc(100%+0.5rem)] left-0 z-30 m-0 flex items-center rounded-md bg-foreground px-2 py-1 text-xs font-semibold whitespace-nowrap text-background',\n 'before:absolute before:-top-[3px] before:left-[0.9rem] before:size-2 before:rotate-45 before:rounded-[1px] before:bg-foreground',\n // `translate`, not `transform`: v4 utilities set the separate\n // `translate` property, which a transform transition never sees.\n '-translate-y-[3px] opacity-0 transition-[opacity,translate] duration-150',\n 'group-has-[.qj-dormant:hover]:translate-y-0 group-has-[.qj-dormant:hover]:opacity-100',\n 'group-has-[.qj-dormant:focus-visible]:translate-y-0 group-has-[.qj-dormant:focus-visible]:opacity-100',\n )}\n >\n Type a {selected.identity} here first\n </p>\n </div>\n\n {/* A real <button> while dormant. An <a> without href is not focusable,\n so the whole \"press it and it tells you to type\" behaviour — and the\n hint's :focus-visible branch — would have been mouse-only. */}\n {href ? (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n // Title, not the url: the browser already puts the url in the\n // status bar, and some of these are 250 characters long. It doubles\n // as the full text when the picker has ellipsised it.\n title={`Jump to ${selected.title}`}\n className={cn(JUMP, JUMP_FILL)}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </a>\n ) : (\n <button\n type=\"button\"\n aria-describedby={hintId}\n onClick={() => inputRef.current?.focus()}\n // Tinted toward the action rather than greyed: on `bg-muted` it was\n // the picker's twin, so the row read as two dropdowns around a\n // field and nothing in it looked raised.\n className={cn(\n JUMP,\n 'qj-dormant cursor-text bg-primary/12 text-muted-foreground',\n )}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </button>\n )}\n </div>\n )\n}\n"],"names":["React","value"],"mappings":"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"}
|
|
@@ -8,7 +8,7 @@ import { Button } from "../../../../ui/button.js";
|
|
|
8
8
|
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "../../../../ui/accordion.js";
|
|
9
9
|
import { AccessRequestSection } from "../components/AccessRequestSection.js";
|
|
10
10
|
import { AccessPrerequisiteChain } from "../components/AccessPrerequisiteChain.js";
|
|
11
|
-
import { LEAP, LEAVE_FILL, QuickJumpBar } from "../components/QuickJumpBar.js";
|
|
11
|
+
import { LEAP, QUIET_FILL, LEAVE_FILL, hasQuickJumps, QuickJumpBar } from "../components/QuickJumpBar.js";
|
|
12
12
|
import { useAppCatalogFilters } from "../context/AppCatalogFiltersContext.js";
|
|
13
13
|
import { PersonBadge } from "../components/PersonBadge.js";
|
|
14
14
|
import { useUser } from "../../../auth/AuthContext.js";
|
|
@@ -210,8 +210,13 @@ function AppDetails({
|
|
|
210
210
|
rel: "noopener noreferrer",
|
|
211
211
|
onClick: () => recordClick(app.slug),
|
|
212
212
|
className: cn(
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
// The row's one inset and icon gap, and its height stated
|
|
214
|
+
// rather than left to the taller neighbour.
|
|
215
|
+
"inline-flex min-h-[34px] items-center gap-1.5 rounded-md px-3 text-sm font-medium shadow-sm",
|
|
216
|
+
// Only ever ONE solid brand fill in this row: it goes to
|
|
217
|
+
// Quick Jump's submit when there is one, because that is
|
|
218
|
+
// the control that acts on something the user typed.
|
|
219
|
+
hasQuickJumps(app) ? QUIET_FILL : LEAVE_FILL,
|
|
215
220
|
LEAP
|
|
216
221
|
),
|
|
217
222
|
title: displayUrl(app.appUrl),
|