@behindthemusictree/app-kit-ui 35.0.0 → 37.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -130,6 +130,7 @@ type SearchBoxProps = {
130
130
  onPicked?: (groupKey: string, itemKey: string) => void;
131
131
  /** Shows the clear button when set. */
132
132
  onClear?: () => void;
133
+ clearLabel?: string;
133
134
  /** An action left of the text, e.g. a Previous button. */
134
135
  leading?: ReactNode;
135
136
  className?: string;
@@ -137,7 +138,7 @@ type SearchBoxProps = {
137
138
  onFocus?: () => void;
138
139
  };
139
140
  /** A search pill over any number of result groups; picking an item calls that item's `onSelect`. */
140
- declare function SearchBox({ label, placeholder, value, onValueChange, groups, onPicked, onClear, leading, className, onFocus, }: SearchBoxProps): react.JSX.Element;
141
+ declare function SearchBox({ label, placeholder, value, onValueChange, groups, onPicked, onClear, clearLabel, leading, className, onFocus, }: SearchBoxProps): react.JSX.Element;
141
142
 
142
143
  declare function useArtistSearchGroup(getBaseUrl: () => string, text: string, onSelect: (artist: ArtistSearchResult) => void, enabled?: boolean): SearchGroup | undefined;
143
144
 
@@ -165,6 +166,9 @@ type GenreSearchProps<T extends CriteriaPlaylistSimple = CriteriaPlaylistSimple>
165
166
  onPrevious?: () => void;
166
167
  /** Called by the clear button, after it empties the input. */
167
168
  onClear?: () => void;
169
+ /** Forces the clear button on or off; by default it shows while the input has something to clear. */
170
+ showClear?: boolean;
171
+ clearLabel?: string;
168
172
  placeholder?: string;
169
173
  /** Called with what the user types, and with "" on clear or pick; never when `selectedName` refills the input. */
170
174
  onQueryChange?: (query: string) => void;
@@ -173,7 +177,7 @@ type GenreSearchProps<T extends CriteriaPlaylistSimple = CriteriaPlaylistSimple>
173
177
  /** Called when the input gains focus. */
174
178
  onFocus?: () => void;
175
179
  };
176
- declare function GenreSearch<T extends CriteriaPlaylistSimple = CriteriaPlaylistSimple>({ genrePlaylists, onSelect, selectedName, onClear, onPrevious, placeholder, onQueryChange, extraGroups, onFocus, }: GenreSearchProps<T>): react.JSX.Element;
180
+ declare function GenreSearch<T extends CriteriaPlaylistSimple = CriteriaPlaylistSimple>({ genrePlaylists, onSelect, selectedName, onClear, showClear, clearLabel, onPrevious, placeholder, onQueryChange, extraGroups, onFocus, }: GenreSearchProps<T>): react.JSX.Element;
177
181
 
178
182
  interface GenreSearchGroupsProps {
179
183
  query: string;
@@ -393,9 +397,10 @@ type GenreDetailPanelProps<O extends CriteriaOverview> = {
393
397
  /** Tab on the panel's inner edge, for hosts that hide and show the panel. */
394
398
  edgeTab?: ComponentProps<typeof DetailPanel>["edgeTab"];
395
399
  extras?: GenreInfoPanelBodyProps<O>["extras"];
396
- headerClassName?: ComponentProps<typeof DetailPanel>["headerClassName"];
400
+ /** The host's search field shows this genre's name and closes the panel. */
401
+ titleInSearch?: boolean;
397
402
  };
398
- declare function GenreDetailPanel<O extends CriteriaOverview>({ genreUuid, scope, getBackendBaseUrl, onClose, onSelectGenre, onZoomIn, shareUrl, shareTarget, collapsed, expanded, onExpandedChange, edgeTab, extras, headerClassName, }: GenreDetailPanelProps<O>): react.JSX.Element;
403
+ declare function GenreDetailPanel<O extends CriteriaOverview>({ genreUuid, scope, getBackendBaseUrl, onClose, onSelectGenre, onZoomIn, shareUrl, shareTarget, collapsed, expanded, onExpandedChange, edgeTab, extras, titleInSearch, }: GenreDetailPanelProps<O>): react.JSX.Element;
399
404
 
400
405
  type GenrePlaylistTreePerRootProps<T extends TrackBase> = {
401
406
  scope: Scope;
@@ -21,7 +21,7 @@ import {
21
21
  useGenrePlaylistPlayback,
22
22
  useGenreRemoteSearchGroup,
23
23
  useSongSearchGroup
24
- } from "../chunk-YC3WBUVO.js";
24
+ } from "../chunk-FTYMRCJS.js";
25
25
  import "../chunk-DKQQLLMY.js";
26
26
  export {
27
27
  GenreDetailPanel,
package/dist/index.cjs CHANGED
@@ -1047,6 +1047,7 @@ function SearchBox({
1047
1047
  groups,
1048
1048
  onPicked,
1049
1049
  onClear,
1050
+ clearLabel,
1050
1051
  leading,
1051
1052
  className: className2,
1052
1053
  onFocus
@@ -1078,6 +1079,7 @@ function SearchBox({
1078
1079
  status,
1079
1080
  loading,
1080
1081
  onClear,
1082
+ clearLabel,
1081
1083
  leading,
1082
1084
  inputClassName: "focus-visible:ring-0"
1083
1085
  }
@@ -1173,6 +1175,8 @@ function GenreSearch({
1173
1175
  onSelect,
1174
1176
  selectedName = null,
1175
1177
  onClear,
1178
+ showClear,
1179
+ clearLabel,
1176
1180
  onPrevious,
1177
1181
  placeholder,
1178
1182
  onQueryChange,
@@ -1220,7 +1224,8 @@ function GenreSearch({
1220
1224
  if (groupKey !== "genres") setQuery("");
1221
1225
  onQueryChange?.("");
1222
1226
  },
1223
- onClear: canClear ? () => {
1227
+ clearLabel,
1228
+ onClear: showClear ?? canClear ? () => {
1224
1229
  clear();
1225
1230
  onQueryChange?.("");
1226
1231
  } : void 0,
@@ -1715,7 +1720,7 @@ function GenrePlaylistTracks({
1715
1720
  const trackNoun = (0, import_track_noun2.useTrackNoun)();
1716
1721
  const uuid = genrePlaylist?.uuid;
1717
1722
  const genreName = selectedGenreName ?? genrePlaylist?.name ?? "Genre";
1718
- const { fetch } = (0, import_transport5.useFetchWrapper)(getBackendBaseUrl);
1723
+ const { fetchBound } = (0, import_transport5.usePlaybackFetch)(getBackendBaseUrl);
1719
1724
  const playback = (0, import_transport5.usePlaybackContext)();
1720
1725
  const { data, isPending, isError, refetch, hasNextPage, isFetchingNextPage, fetchNextPage } = (0, import_react_query.useInfiniteQuery)({
1721
1726
  queryKey: [...scope === "me" ? uuid ? import_genre_tree12.genrePlaylistQueryKeys.me.tracks(uuid) : ["missing-personal-genre-playlist", genreUuid] : artist ? [...import_genre_tree13.libraryQueryKeys.reference.songs.byGenre(getBackendBaseUrl(), genreUuid, selection), "artist", artist.uuid] : import_genre_tree13.libraryQueryKeys.reference.songs.byGenre(getBackendBaseUrl(), genreUuid, selection), ...playback.key ? [playback.key] : []],
@@ -1724,7 +1729,7 @@ function GenrePlaylistTracks({
1724
1729
  if (!uuid) throw new Error("Personal genre has no playlist");
1725
1730
  return fetchGenrePlaylistTracksPage(uuid, scope, Number(pageParam));
1726
1731
  }
1727
- const response = await fetch(
1732
+ const response = await fetchBound(
1728
1733
  import_genre_tree13.libraryEndpoints.reference.songs.list(),
1729
1734
  true,
1730
1735
  false,
@@ -1736,7 +1741,9 @@ function GenrePlaylistTracks({
1736
1741
  },
1737
1742
  initialPageParam: scope === "me" ? 1 : "",
1738
1743
  getNextPageParam: (lastPage) => lastPage.nextPage ?? void 0,
1739
- enabled: playback.ready && (scope !== "me" || uuid !== void 0 && sessionRestored && !!session?.accessToken)
1744
+ enabled: playback.ready && (scope !== "me" || uuid !== void 0 && sessionRestored && !!session?.accessToken),
1745
+ retry: (failureCount, error) => failureCount < 1 && error instanceof DOMException && error.name === "AbortError",
1746
+ retryDelay: 0
1740
1747
  });
1741
1748
  const [sentinel, setSentinel] = (0, import_react12.useState)(null);
1742
1749
  (0, import_react12.useEffect)(() => {
@@ -2055,10 +2062,12 @@ function useGenreDetailContent({
2055
2062
  };
2056
2063
  }, [detail, scope]);
2057
2064
  if (isError) return {
2065
+ name: null,
2058
2066
  title: "Genre unavailable",
2059
2067
  content: /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("p", { role: "alert", className: "p-4 text-sm text-[var(--color-text)]", children: "This genre could not be loaded." })
2060
2068
  };
2061
2069
  if (isPending || !detail || !view) return {
2070
+ name: null,
2062
2071
  title: "Loading genre\u2026",
2063
2072
  content: /* @__PURE__ */ (0, import_jsx_runtime32.jsxs)("div", { className: "flex flex-col gap-3 p-4", "aria-busy": "true", "data-testid": "genre-detail-panel-skeleton", children: [
2064
2073
  /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(import_ui10.Skeleton, { className: "h-6 w-2/3" }),
@@ -2080,6 +2089,7 @@ function useGenreDetailContent({
2080
2089
  );
2081
2090
  const ancestors = /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(import_genre_tree_view4.GenreNodeAncestors, { ancestorNodes: view.ancestorNodes, onSelectNode: selectGenre });
2082
2091
  return {
2092
+ name: detail.name,
2083
2093
  title: detail.name,
2084
2094
  headerAccent: { background: view.fill, color: import_genre_tree_view4.ACCENT_TEXT_COLOR },
2085
2095
  aside: expanded ? ancestors : void 0,
@@ -2121,7 +2131,7 @@ function GenreDetailPanel({
2121
2131
  onExpandedChange,
2122
2132
  edgeTab,
2123
2133
  extras,
2124
- headerClassName
2134
+ titleInSearch
2125
2135
  }) {
2126
2136
  const panel = useGenreDetailContent({
2127
2137
  genreUuid,
@@ -2144,7 +2154,7 @@ function GenreDetailPanel({
2144
2154
  onExpandedChange,
2145
2155
  edgeTab,
2146
2156
  aside: panel.aside,
2147
- headerClassName,
2157
+ titleInSearch,
2148
2158
  headerActions: onExpandedChange ? /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(import_ui10.IconButton, { "aria-label": expanded ? "Shrink panel" : "Extend panel", onClick: () => onExpandedChange(!expanded), children: expanded ? /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(import_lucide_react12.Minimize2, { "aria-hidden": "true", className: "h-4 w-4" }) : /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(import_lucide_react12.Maximize2, { "aria-hidden": "true", className: "h-4 w-4" }) }) : void 0,
2149
2159
  children: panel.content
2150
2160
  }
@@ -2512,12 +2522,12 @@ var SORTS = [
2512
2522
  function ArtistSongs({ artist, getBackendBaseUrl, renderArtistLink, renderSongLink }) {
2513
2523
  const [page, setPage] = (0, import_react16.useState)(1);
2514
2524
  const [ordering, setOrdering] = (0, import_react16.useState)("title");
2515
- const { fetch } = (0, import_transport9.useFetchWrapper)(getBackendBaseUrl);
2516
2525
  const { playNewTrackListFromArtist } = (0, import_genre_tree24.useTrackList)();
2517
2526
  const { playerTrackObject } = (0, import_player10.usePlayer)();
2518
- const { data, isPending, isError } = (0, import_transport9.useQueryWithParse)({
2527
+ const { data, isPending, isError } = (0, import_transport9.usePlaybackQuery)({
2528
+ getBackendBaseUrl,
2519
2529
  queryKey: ["referenceSongs", "artist", artist.uuid, ordering, page],
2520
- queryFn: () => fetch(import_genre_tree24.libraryEndpoints.reference.songs.list(), true, false, void 0, {
2530
+ queryFn: (fetchBound) => fetchBound(import_genre_tree24.libraryEndpoints.reference.songs.list(), true, false, void 0, {
2521
2531
  artist: artist.uuid,
2522
2532
  ordering,
2523
2533
  page
@@ -2955,6 +2965,8 @@ function GenreTreeView({
2955
2965
  };
2956
2966
  const {
2957
2967
  isLoading,
2968
+ isError,
2969
+ refetch,
2958
2970
  isFetching: isTreeFetching,
2959
2971
  genrePlaylistByNodeId,
2960
2972
  visibleGenrePlaylists,
@@ -3087,7 +3099,7 @@ function GenreTreeView({
3087
3099
  }, [selectedGenreUuid, artistUuid, songUuid]);
3088
3100
  const [isWheelRevealed, setIsWheelRevealed] = (0, import_react18.useState)(false);
3089
3101
  const usesHandoff = viewMode === "wheel" || viewMode === "pop-core";
3090
- const isTreeReady = !isLoading && (!usesHandoff || isWheelRevealed);
3102
+ const isTreeReady = !isLoading && !isError && (!usesHandoff || isWheelRevealed);
3091
3103
  const [resolveStyle, setResolveStyle] = (0, import_react18.useState)(null);
3092
3104
  const handleNodeStyleResolverChange = (0, import_react18.useCallback)(
3093
3105
  (resolver) => setResolveStyle(() => resolver),
@@ -3118,7 +3130,6 @@ function GenreTreeView({
3118
3130
  if (node !== null && songUuid !== null) changeSong(null);
3119
3131
  selectNode(node);
3120
3132
  };
3121
- const handleGenreSearchClear = clearSelection;
3122
3133
  const handleGenreSearchSelect = (genrePlaylist2) => {
3123
3134
  reopenPanel();
3124
3135
  if (songUuid !== null) changeSong(null);
@@ -3201,7 +3212,7 @@ function GenreTreeView({
3201
3212
  expanded: overlayPanel.expanded ?? false,
3202
3213
  onExpandedChange: overlayPanel.onExpandedChange,
3203
3214
  onClose: overlayPanel.onClose,
3204
- compact: false
3215
+ titleInSearch: false
3205
3216
  };
3206
3217
  } else if (selectedGenreUuid) {
3207
3218
  activePanel = {
@@ -3211,7 +3222,7 @@ function GenreTreeView({
3211
3222
  expanded: isExpanded,
3212
3223
  onExpandedChange: changeExpanded,
3213
3224
  onClose: clearSelection,
3214
- compact: false,
3225
+ titleInSearch: true,
3215
3226
  headerAccent: relations ? { background: relations.fill, color: relations.textColor } : genreContent.headerAccent
3216
3227
  };
3217
3228
  } else if (builtInPanel) {
@@ -3222,7 +3233,7 @@ function GenreTreeView({
3222
3233
  expanded: builtInPanel.expanded,
3223
3234
  onExpandedChange: builtInPanel.onExpandedChange,
3224
3235
  onClose: builtInPanel.onClose,
3225
- compact: true
3236
+ titleInSearch: true
3226
3237
  };
3227
3238
  }
3228
3239
  const isPanelShown = activePanel !== null && (selectedGenreUuid !== null || isTreeReady);
@@ -3267,7 +3278,7 @@ function GenreTreeView({
3267
3278
  ] });
3268
3279
  };
3269
3280
  const actions = /* @__PURE__ */ (0, import_jsx_runtime46.jsxs)(import_jsx_runtime46.Fragment, { children: [
3270
- !isLoading && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
3281
+ !isLoading && !isError && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
3271
3282
  GenreTreeSearch,
3272
3283
  {
3273
3284
  artistBaseUrl,
@@ -3290,17 +3301,19 @@ function GenreTreeView({
3290
3301
  if (songUuid) changeSong(null);
3291
3302
  selectGenre(uuid);
3292
3303
  },
3293
- selectedName: (selectedGenreUuid ? genreContent.title : null) ?? (typeof builtInPanel?.title === "string" ? builtInPanel.title : null),
3294
- onClear: builtInSongPanel ? () => changeSong(null) : builtInArtistPanel ? () => changeArtist(null) : handleGenreSearchClear,
3304
+ selectedName: selectedGenreUuid ? genreContent.name : typeof builtInPanel?.title === "string" ? builtInPanel.title : null,
3305
+ onClear: activePanel?.onClose,
3306
+ showClear: activePanel?.titleInSearch ?? false,
3307
+ clearLabel: "Close panel",
3295
3308
  onPrevious: handlePrevious,
3296
3309
  onQueryChange: onSearchQueryChange,
3297
3310
  onFocus: onSearchFocus,
3298
3311
  extraGroups: extraSearchGroups
3299
3312
  }
3300
3313
  ),
3301
- !isLoading && filterArtist && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)("div", { className: "hidden w-full justify-center max-md:flex", children: renderArtistFilter() }),
3314
+ !isLoading && !isError && filterArtist && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)("div", { className: "hidden w-full justify-center max-md:flex", children: renderArtistFilter() }),
3302
3315
  /* @__PURE__ */ (0, import_jsx_runtime46.jsxs)("div", { className: "contents max-md:flex max-md:w-full max-md:items-center max-md:gap-2 max-md:overflow-x-auto max-md:[scrollbar-width:none] max-md:[&>*]:shrink-0", children: [
3303
- !isLoading && !isControlled && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
3316
+ !isLoading && !isError && !isControlled && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
3304
3317
  import_ui20.SegmentedControl,
3305
3318
  {
3306
3319
  "aria-label": "Tree view mode",
@@ -3314,7 +3327,7 @@ function GenreTreeView({
3314
3327
  ]
3315
3328
  }
3316
3329
  ),
3317
- !isLoading && showDisplayOptions && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
3330
+ !isLoading && !isError && showDisplayOptions && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
3318
3331
  "div",
3319
3332
  {
3320
3333
  className: "flex items-center gap-1",
@@ -3331,7 +3344,7 @@ function GenreTreeView({
3331
3344
  )
3332
3345
  }
3333
3346
  ),
3334
- !isLoading && !readOnly && showAddRoot && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
3347
+ !isLoading && !isError && !readOnly && showAddRoot && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
3335
3348
  import_ui20.IconTextButton,
3336
3349
  {
3337
3350
  icon: import_lucide_react16.Plus,
@@ -3354,7 +3367,7 @@ function GenreTreeView({
3354
3367
  children: actions
3355
3368
  }
3356
3369
  ),
3357
- !isLoading && filterArtist && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
3370
+ !isLoading && !isError && filterArtist && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(
3358
3371
  "div",
3359
3372
  {
3360
3373
  className: `absolute left-1/2 z-30 hidden -translate-x-1/2 md:block ${placement === "regions" ? "top-[calc(var(--layout-gutter)+var(--layout-panel-inset))]" : "top-5"}`,
@@ -3362,8 +3375,12 @@ function GenreTreeView({
3362
3375
  }
3363
3376
  ),
3364
3377
  /* @__PURE__ */ (0, import_jsx_runtime46.jsx)("div", { className: "content-container flex-1 min-h-0 flex flex-row gap-4", children: /* @__PURE__ */ (0, import_jsx_runtime46.jsxs)("div", { className: "tree-view-container relative flex-1 min-w-0 flex flex-col h-full", children: [
3365
- !isLoading && viewMode === "outline" && floatingControlsLeading && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)("div", { className: "absolute bottom-3 right-3 z-10", children: floatingControlsLeading }),
3366
- isLoading ? /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(import_genre_tree_view5.GenreTreeViewSkeleton, { viewMode }) : viewMode === "wheel" ? (
3378
+ !isLoading && !isError && viewMode === "outline" && floatingControlsLeading && /* @__PURE__ */ (0, import_jsx_runtime46.jsx)("div", { className: "absolute bottom-3 right-3 z-10", children: floatingControlsLeading }),
3379
+ isLoading ? /* @__PURE__ */ (0, import_jsx_runtime46.jsx)(import_genre_tree_view5.GenreTreeViewSkeleton, { viewMode }) : isError ? /* @__PURE__ */ (0, import_jsx_runtime46.jsxs)("p", { role: "alert", className: "m-auto text-center", children: [
3380
+ "Couldn't load the genre tree.",
3381
+ " ",
3382
+ /* @__PURE__ */ (0, import_jsx_runtime46.jsx)("button", { type: "button", className: "underline", onClick: refetch, children: "Retry" })
3383
+ ] }) : viewMode === "wheel" ? (
3367
3384
  // The info panel's top edge aligns with the tree's; the search bar floats `--layout-panel-inset` below it,
3368
3385
  // and the header inset (the bar's height plus that inset) extends the header's fill behind the bar,
3369
3386
  // Google Maps style; the 8px bottom padding mirrors the inset above the bar, and the hidden title's height is zeroed.
@@ -3477,8 +3494,7 @@ function GenreTreeView({
3477
3494
  ref: panelRef,
3478
3495
  side: "left",
3479
3496
  title: activePanel?.title ?? "",
3480
- hideTitle: activePanel?.compact,
3481
- hideClose: activePanel?.compact,
3497
+ titleInSearch: activePanel?.titleInSearch,
3482
3498
  headerAccent: activePanel?.headerAccent,
3483
3499
  headerActions: expandButton,
3484
3500
  onClose: activePanel?.onClose ?? noop,
@@ -3487,7 +3503,7 @@ function GenreTreeView({
3487
3503
  onExpandedChange: activePanel?.onExpandedChange,
3488
3504
  aside: activePanel?.aside,
3489
3505
  edgeTab: detailTab,
3490
- headerClassName: "pt-[calc(var(--layout-search-h)+var(--layout-panel-inset)+0.5rem)]",
3506
+ headerClassName: activePanel?.titleInSearch ? void 0 : "pt-[calc(var(--layout-search-h)+var(--layout-panel-inset)+0.5rem)]",
3491
3507
  children: activePanel?.aside && !activePanel.expanded ? /* @__PURE__ */ (0, import_jsx_runtime46.jsxs)(import_jsx_runtime46.Fragment, { children: [
3492
3508
  activePanel.content,
3493
3509
  activePanel.aside