@hashsome/ui 0.10.0 → 0.11.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.
@@ -18,6 +18,18 @@ import { useMediaBrowser } from '../use-media-browser.js';
18
18
  import { trackMedia, useMediaPending } from './media-pending.js';
19
19
  /** The height of the search field, and of the row of tabs it replaces. */
20
20
  const SEARCH_HEIGHT = 36;
21
+ /** The name of each kind of thing a search finds, on the chip that narrows the results to it. */
22
+ const KIND_LABELS = {
23
+ album: 'Albums',
24
+ artist: 'Artists',
25
+ track: 'Tracks',
26
+ playlist: 'Playlists',
27
+ radio: 'Radio',
28
+ folder: 'Folders',
29
+ other: 'Other',
30
+ };
31
+ /** The chip for every kind at once. */
32
+ const ALL_KINDS = 'all';
21
33
  const KIND_ICONS = {
22
34
  folder: 'lu:folder',
23
35
  artist: 'lu:mic-vocal',
@@ -95,9 +107,23 @@ export function MediaBrowser({ entity, onPlay, layout = 'list', search: searchMo
95
107
  }
96
108
  },
97
109
  }
98
- : {}), css: { background: 'none', outline: 'none' } }), onDemand ? (_jsx(PlainButton, { "aria-label": "Close search", title: "Close search", onClick: closeSearch, center: true, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:x", size: 16 }) })) : browser.query !== '' ? (_jsx(PlainButton, { "aria-label": "Clear search", title: "Clear search", onClick: () => browser.search(''), center: true, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:x", size: 16 }) })) : null] })) : null;
99
- /** The categories (and, with `search='icon'`, the search icon at the end). */
100
- const tabsRow = browser.tabs ? (_jsxs(Flex, { align: "center", gap: 3, ...(onDemand ? { minHeight: SEARCH_HEIGHT } : {}), children: [_jsx(Flex, { direction: "column", grow: 1, minWidth: 0, children: _jsx(ChipRow, { tabs: true, options: browser.tabs.map((item) =>
110
+ : {}), css: {
111
+ background: 'none',
112
+ outline: 'none',
113
+ // A search field gets a clear button of the browser's own in some browsers, and this one has its own
114
+ // (and a button to close it), so the browser's is not drawn.
115
+ '&::-webkit-search-cancel-button, &::-webkit-search-decoration': {
116
+ WebkitAppearance: 'none',
117
+ appearance: 'none',
118
+ display: 'none',
119
+ },
120
+ } }), onDemand ? (_jsx(PlainButton, { "aria-label": "Close search", title: "Close search", onClick: closeSearch, center: true, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:x", size: 16 }) })) : browser.query !== '' ? (_jsx(PlainButton, { "aria-label": "Clear search", title: "Clear search", onClick: () => browser.search(''), center: true, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:x", size: 16 }) })) : null] })) : null;
121
+ /** The categories (and, with `search='icon'`, the search icon at the end). While a search has results, the
122
+ * same row is the kinds to narrow it to: the shelves mean nothing to a search's results, which belong to none. */
123
+ const tabsRow = browser.tabs ? (_jsxs(Flex, { align: "center", gap: 3, ...(onDemand ? { minHeight: SEARCH_HEIGHT } : {}), children: [_jsx(Flex, { direction: "column", grow: 1, minWidth: 0, children: browser.searching && browser.kindsPending ? (_jsx(KindsSkeleton, {})) : browser.searching && browser.kinds ? (_jsx(ChipRow, { tabs: true, options: [
124
+ { value: ALL_KINDS, label: 'All' },
125
+ ...browser.kinds.map((kind) => ({ value: kind, label: KIND_LABELS[kind] })),
126
+ ], value: browser.kind ?? ALL_KINDS, onChange: (id) => browser.filterBy(id === ALL_KINDS ? undefined : id) })) : (_jsx(ChipRow, { tabs: true, options: browser.tabs.map((item) =>
101
127
  // Inside a shelf the selected tab becomes the way back, so nothing is added to the
102
128
  // layout and nothing below it moves.
103
129
  item.id === browser.activeTab && inside
@@ -107,11 +133,15 @@ export function MediaBrowser({ entity, onPlay, layout = 'list', search: searchMo
107
133
  icon: 'lu:arrow-left',
108
134
  ariaLabel: 'Back',
109
135
  }
110
- : { value: item.id, label: item.title }), value: browser.activeTab, onChange: (id) => id === browser.activeTab && inside ? browser.back() : browser.selectTab(id) }) }), actionsHere, searchTrigger] })) : null;
136
+ : { value: item.id, label: item.title }), value: browser.activeTab, onChange: (id) => id === browser.activeTab && inside ? browser.back() : browser.selectTab(id) })) }), actionsHere, searchTrigger] })) : null;
111
137
  return (_jsxs(Flex, { direction: "column", gap: 3, minHeight: 0, position: "relative", children: [onDemand ? (_jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsx(motion.div, { initial: { opacity: 0, x: fieldShown ? 28 : 0 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: fieldShown ? 0 : 28 }, transition: { duration: 0.18, ease: 'easeOut' }, children: fieldShown ? search : browser.tabs === undefined ? header : tabsRow }, fieldShown ? 'field' : 'row') })) : (_jsxs(_Fragment, { children: [browser.tabs === undefined ? header : null, search, tabsRow] })), browser.error ? (_jsx(Message, { theater: theater, children: _jsx(Typography, { as: "p", variant: "body", color: "danger", role: "alert", m: 0, children: browser.error }) })) : browser.loading ? (_jsx(BrowseSkeleton, { theater: theater })) : browser.items.length === 0 ? (_jsx(Message, { theater: theater, children: _jsx(Typography, { as: "p", variant: "body", color: "textMuted", m: 0, children: browser.searching ? 'Nothing found.' : 'Nothing here.' }) })) : theater ? (_jsx(FadeScroll, { as: "ul", gap: 4, m: 0, p: 0, pb: 2, css: { ...THEATER_STAGE, listStyle: 'none', scrollSnapType: 'x proximity' }, children: browser.items.map((item, index) => (_jsx("li", { css: { flex: 'none', scrollSnapAlign: 'start' }, children: _jsx(Reveal, { index: browser.searching ? 0 : index, children: _jsx(BrowseCard, { item: item, asked: askedOf(item), queueable: queueable, onOpen: () => browser.open(item), onPlay: () => play(item), onAdd: () => addToQueue(item) }) }) }, `${browser.listKey}:${item.id}`))) })) : (_jsx(Flex, { direction: "column", gap: 1, as: "ul", m: 0, p: 0, minHeight: 0, css: { listStyle: 'none', overflowY: 'auto' }, children: browser.items.map((item, index) => (_jsx("li", { css: { flex: 'none' }, children: _jsx(Reveal, { index: browser.searching ? 0 : index, children: _jsx(BrowseRow, { item: item, asked: askedOf(item), queueable: queueable, onOpen: () => browser.open(item), onPlay: () => play(item), onNext: () => playNext(item), onAdd: () => addToQueue(item) }) }) }, `${browser.listKey}:${item.id}`))) }))] }));
112
138
  }
113
139
  /** What shows where the list will be while it loads: placeholders shaped like what is coming (cards in
114
140
  * the theater layout, rows in the list), in the room the real thing takes, so nothing moves. */
141
+ /** Where the kinds to narrow a search to will be, in the chips' own height, so the list below does not move. */
142
+ function KindsSkeleton() {
143
+ return (_jsx(Flex, { gap: 2, "aria-busy": "true", css: { height: CHIP_HEIGHT }, children: [40, 78, 74, 70, 62].map((width) => (_jsx(Skeleton, { width: width, height: CHIP_HEIGHT, radius: "full" }, width))) }));
144
+ }
115
145
  function BrowseSkeleton({ theater }) {
116
146
  return theater ? (_jsx(Flex, { as: "ul", role: "status", "aria-label": "Loading", "aria-busy": "true", gap: 4, m: 0, p: 0, pb: 2, css: { ...THEATER_STAGE, listStyle: 'none', overflow: 'hidden' }, children: Array.from({ length: 12 }, (_, index) => (_jsxs(Flex, { as: "li", direction: "column", gap: 2, css: { flex: 'none', width: CARD_SIZE }, children: [_jsx(Skeleton, { width: CARD_SIZE, height: CARD_SIZE, radius: "card" }), _jsxs(Flex, { direction: "column", gap: 1, px: 1, children: [_jsx(Skeleton, { width: "70%", height: 14 }), _jsx(Skeleton, { width: "45%", height: 12 })] })] }, index))) })) : (_jsx(Flex, { as: "ul", role: "status", "aria-label": "Loading", "aria-busy": "true", direction: "column", gap: 1, m: 0, p: 0, css: { listStyle: 'none' }, children: Array.from({ length: 6 }, (_, index) => (_jsxs(Flex, { as: "li", align: "center", gap: 3, py: 1.5, px: 2, children: [_jsx(Skeleton, { width: 44, height: 44 }), _jsxs(Flex, { direction: "column", gap: 1.5, grow: 1, children: [_jsx(Skeleton, { width: "60%", height: 14 }), _jsx(Skeleton, { width: "35%", height: 12 })] })] }, index))) }));
117
147
  }
@@ -85,6 +85,9 @@ export function DebugMenu({ configured, sun, }) {
85
85
  };
86
86
  const theme = debug.themeChoice ?? configuredChoice(configured);
87
87
  return (_jsxs(Flex, { ref: root, "data-debug-menu": true,
88
+ // Over the page and its navigation but under a drawer and its scrim, so opening a drawer covers it
89
+ // instead of leaving it floating over the drawer.
90
+ zIndex: "dropdown",
88
91
  // As big as the button, whatever the page's own rules say: the document shell makes every `div`
89
92
  // that is a child of `body` at least as tall as the window (`body > div { min-height: 100% }`),
90
93
  // which this is, and the button would sit at the top of it.
@@ -92,7 +95,6 @@ export function DebugMenu({ configured, sun, }) {
92
95
  position: 'fixed',
93
96
  left: INSET,
94
97
  bottom: INSET,
95
- zIndex: 30,
96
98
  width: BUTTON,
97
99
  height: BUTTON,
98
100
  minHeight: 0,
@@ -1,4 +1,4 @@
1
- import type { BrowseItem, EntityRef } from '@hashsome/core';
1
+ import type { BrowseItem, BrowseKind, EntityRef } from '@hashsome/core';
2
2
  export interface MediaBrowserState {
3
3
  /** What is listed now. */
4
4
  items: BrowseItem[];
@@ -27,6 +27,14 @@ export interface MediaBrowserState {
27
27
  query: string;
28
28
  /** Lists what matches `text`; an empty string goes back to where the browser was. */
29
29
  search(text: string): void;
30
+ /** The kinds of thing the search found (albums, playlists…), when it found any, so there is something to narrow it to (to a lone kind too, which brings far more of it than the mixed list); in a fixed order. Absent when not searching. While another search is on its way these are the last one's, so what is shown does not flicker as someone types. */
31
+ kinds: BrowseKind[] | undefined;
32
+ /** Searching, with no kinds to show yet (the first answer has not come) and nothing from an earlier search to show instead: a placeholder for them. */
33
+ kindsPending: boolean;
34
+ /** The kind the results are narrowed to; absent for all of them. */
35
+ kind: BrowseKind | undefined;
36
+ /** Narrows the search results to one kind, which asks for more of it than the mixed list has, or back to all of them. */
37
+ filterBy(kind: BrowseKind | undefined): void;
30
38
  }
31
39
  /** Walks a player's own media library through the runtime. The top level is read once: shelves
32
40
  * become tabs (the first is open to begin with), anything else is a plain list. Below it, a stack
@@ -1,6 +1,18 @@
1
1
  import { useCallback, useEffect, useState } from 'react';
2
2
  import { useReconnects } from './hooks.js';
3
3
  import { useClient } from './provider.js';
4
+ /** The order the kinds of a search result are offered in: what is played most, first. */
5
+ const KIND_ORDER = [
6
+ 'track',
7
+ 'playlist',
8
+ 'album',
9
+ 'artist',
10
+ 'radio',
11
+ 'folder',
12
+ 'other',
13
+ ];
14
+ /** The kinds among some items, in the order they are offered. */
15
+ const kindsOf = (items) => KIND_ORDER.filter((candidate) => items.some((item) => item.kind === candidate));
4
16
  /** A top level that is nothing but folders to look inside is a set of shelves, which read better
5
17
  * as tabs than as a list that always looks the same. */
6
18
  const isShelves = (items) => items.length >= 2 && items.every((item) => item.expandable && !item.playable);
@@ -16,6 +28,12 @@ export function useMediaBrowser(ref) {
16
28
  const [trail, setTrail] = useState([{}]);
17
29
  const [text, setText] = useState('');
18
30
  const [tab, setTab] = useState();
31
+ // The kind of result chosen to narrow a search to; only counts while that kind is among the results.
32
+ const [kindChoice, setKindChoice] = useState();
33
+ // The search with no kind chosen: it says which kinds there are, and is the list while none is chosen.
34
+ const [overview, setOverview] = useState(null);
35
+ // The kinds the last answered search found, kept while the next one is on its way.
36
+ const [lastKinds, setLastKinds] = useState();
19
37
  // Answers are kept with the request they answer, so a stale one is never shown for a new one.
20
38
  const [top, setTop] = useState(null);
21
39
  const [answer, setAnswer] = useState(null);
@@ -34,13 +52,49 @@ export function useMediaBrowser(ref) {
34
52
  const atTop = trail.length === 1;
35
53
  const tabs = top && !top.error && isShelves(top.items) ? top.items : undefined;
36
54
  const selected = tabs ? (tabs.find((item) => item.id === tab) ?? tabs[0])?.id : undefined;
37
- // What to ask for, if anything: a search, a folder, or the open tab. A top level that is not
38
- // shelves is already in hand.
55
+ // The kinds the search found, once it has answered, and the one chosen if it is among them.
56
+ const overviewKey = `search:${needle}`;
57
+ const found = searching && overview?.key === overviewKey ? overview : null;
58
+ const foundKinds = found ? kindsOf(found.items) : undefined;
59
+ if (!searching && lastKinds !== undefined) {
60
+ setLastKinds(undefined);
61
+ }
62
+ // What to offer: this search's kinds once they are in, the last search's until then.
63
+ const kinds = searching ? (foundKinds ?? lastKinds) : undefined;
64
+ const kind = kinds && kindChoice !== undefined && kinds.includes(kindChoice) ? kindChoice : undefined;
65
+ // What to ask for, if anything: a folder, the open tab, or one kind of a search's results. A top level
66
+ // that is not shelves is already in hand, and so is the search with no kind chosen.
39
67
  const path = atTop ? selected : here.path;
40
- const key = searching ? `search:${needle}` : `path:${path ?? ''}`;
68
+ const key = searching ? `search:${needle}:${kind ?? ''}` : `path:${path ?? ''}`;
41
69
  const result = answer?.key === key ? answer : null;
70
+ // The search with no kind chosen, always: its kinds are the choices, whichever one is showing.
42
71
  useEffect(() => {
43
- const request = searching ? { search: needle } : path === undefined ? undefined : { path };
72
+ if (!searching) {
73
+ return;
74
+ }
75
+ let current = true;
76
+ const timer = setTimeout(() => {
77
+ client.browse(ref, { search: needle }).then((level) => {
78
+ if (current) {
79
+ setOverview({ key: `search:${needle}`, ...level });
80
+ setLastKinds(kindsOf(level.items));
81
+ }
82
+ }, (error) => current && setOverview({ key: `search:${needle}`, items: [], error: messageOf(error) }));
83
+ }, 300);
84
+ return () => {
85
+ current = false;
86
+ clearTimeout(timer);
87
+ };
88
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
89
+ }, [client, ref, searching, needle, reconnects]);
90
+ useEffect(() => {
91
+ const request = searching && kind
92
+ ? { search: needle, kind }
93
+ : searching
94
+ ? undefined
95
+ : path === undefined
96
+ ? undefined
97
+ : { path };
44
98
  if (!request) {
45
99
  return;
46
100
  }
@@ -48,18 +102,33 @@ export function useMediaBrowser(ref) {
48
102
  const timer = setTimeout(() => {
49
103
  client.browse(ref, request).then((level) => current && setAnswer({ key, ...level }), (error) => current && setAnswer({ key, items: [], error: messageOf(error) }));
50
104
  },
51
- // Typing in the search box waits for a pause; opening a folder or a tab does not.
52
- searching ? 300 : 0);
105
+ // Typing in the search box waits for a pause; opening a folder or a tab or choosing a kind does not.
106
+ 0);
53
107
  return () => {
54
108
  current = false;
55
109
  clearTimeout(timer);
56
110
  };
57
111
  // oxlint-disable-next-line react/exhaustive-effect-dependencies
58
- }, [client, ref, key, searching, needle, path, reconnects]);
59
- const open = useCallback((item) => setTrail((levels) => [...levels, { path: item.id, title: item.title, item }]), []);
112
+ }, [client, ref, key, searching, needle, kind, path, reconnects]);
113
+ // Opening what a search found (an album, a playlist) shows it in place of the results: the search steps
114
+ // aside, and going back from it returns to the results.
115
+ const open = useCallback((item) => {
116
+ const from = text.trim();
117
+ setTrail((levels) => [
118
+ ...levels,
119
+ {
120
+ path: item.id,
121
+ title: item.title,
122
+ item,
123
+ ...(from !== '' ? { from: { text: from, kind } } : {}),
124
+ },
125
+ ]);
126
+ setText('');
127
+ }, [text, kind]);
60
128
  const selectTab = useCallback((id) => {
61
129
  setTab(id);
62
130
  setText('');
131
+ setKindChoice(undefined);
63
132
  setTrail([{}]);
64
133
  }, []);
65
134
  const back = useCallback(() => {
@@ -67,13 +136,18 @@ export function useMediaBrowser(ref) {
67
136
  setText('');
68
137
  return;
69
138
  }
139
+ const left = trail.at(-1);
70
140
  setTrail((levels) => (levels.length > 1 ? levels.slice(0, -1) : levels));
71
- }, [text]);
141
+ if (trail.length > 1 && left?.from !== undefined) {
142
+ setText(left.from.text);
143
+ setKindChoice(left.from.kind);
144
+ }
145
+ }, [text, trail]);
72
146
  // The plain top-level list, when there are no shelves to make tabs of.
73
- const listing = !searching && atTop && !tabs ? top : result;
147
+ const listing = searching && !kind ? found : !searching && atTop && !tabs ? top : result;
74
148
  return {
75
149
  items: listing?.items ?? [],
76
- listKey: searching ? 'search' : `path:${path ?? 'top'}`,
150
+ listKey: searching ? `search:${kind ?? ''}` : `path:${path ?? 'top'}`,
77
151
  title: searching
78
152
  ? (result?.title ?? `Results for “${needle}”`)
79
153
  : atTop
@@ -91,5 +165,9 @@ export function useMediaBrowser(ref) {
91
165
  back,
92
166
  query: text,
93
167
  search: setText,
168
+ kinds: kinds && kinds.length >= 1 ? kinds : undefined,
169
+ kindsPending: searching && foundKinds === undefined && !(lastKinds && lastKinds.length >= 1),
170
+ kind,
171
+ filterBy: setKindChoice,
94
172
  };
95
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashsome/ui",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Design system components and hooks",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@emotion/react": "11.14.0",
19
- "@hashsome/core": "0.10.0",
19
+ "@hashsome/core": "0.11.0",
20
20
  "e-prim": "2.0.1",
21
21
  "motion": "13.4.5",
22
22
  "react": "19.3.0",