@hashsome/ui 0.9.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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/entities/media-browser.js +34 -4
- package/dist/entities/nav-dock.d.ts +4 -2
- package/dist/entities/nav-dock.js +5 -1
- package/dist/entities/nav-rail.d.ts +5 -1
- package/dist/entities/nav-rail.js +9 -1
- package/dist/gallery/props-data.js +18 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/layout/debug-menu.js +3 -1
- package/dist/layout/use-idle-return.d.ts +22 -0
- package/dist/layout/use-idle-return.js +66 -0
- package/dist/provider.d.ts +3 -1
- package/dist/provider.js +3 -2
- package/dist/use-media-browser.d.ts +9 -1
- package/dist/use-media-browser.js +89 -11
- package/package.json +2 -2
|
@@ -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: {
|
|
99
|
-
|
|
100
|
-
|
|
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
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type NavItem } from './nav-rail.tsx';
|
|
2
2
|
export interface NavDockProps {
|
|
3
3
|
/** The pages to link to; `to` is relative to `base`. */
|
|
4
4
|
items: NavItem[];
|
|
@@ -6,8 +6,10 @@ export interface NavDockProps {
|
|
|
6
6
|
base: string;
|
|
7
7
|
/** Adds a light/dark toggle at the end of the dock. Off by default — meant for development or a project that deliberately exposes it, not every kiosk install. */
|
|
8
8
|
showThemeToggle?: boolean;
|
|
9
|
+
/** How long, in ms, the display may be left alone on a page other than the main one (the item whose `to` is `''`) before it goes back to that page; `false` for never. Any touch, click, key or scroll starts the time again. Defaults to `HashsomeProvider`'s `idleReturn`, which is off unless the app turns it on. */
|
|
10
|
+
idleReturn?: number | false;
|
|
9
11
|
}
|
|
10
12
|
/** Floating bottom pill for switching between a dashboard's pages. Must be rendered inside a
|
|
11
13
|
* router. `position: fixed`, floating over the page; it reserves the space it takes in the
|
|
12
14
|
* surrounding `Page`, which pads for it. */
|
|
13
|
-
export declare function NavDock({ items, base, showThemeToggle }: NavDockProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
15
|
+
export declare function NavDock({ items, base, showThemeToggle, idleReturn }: NavDockProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
@@ -5,11 +5,15 @@ import { NavLink } from 'react-router';
|
|
|
5
5
|
import { Icon } from '../icon.js';
|
|
6
6
|
import { usePageInset } from '../layout/page.js';
|
|
7
7
|
import { AttentionDot } from './attention-dot.js';
|
|
8
|
+
import { useIdleReturn, useIdleReturnDefault } from '../layout/use-idle-return.js';
|
|
9
|
+
import { mainPath } from './nav-rail.js';
|
|
8
10
|
import { useThemeToggle } from '../provider.js';
|
|
9
11
|
/** Floating bottom pill for switching between a dashboard's pages. Must be rendered inside a
|
|
10
12
|
* router. `position: fixed`, floating over the page; it reserves the space it takes in the
|
|
11
13
|
* surrounding `Page`, which pads for it. */
|
|
12
|
-
export function NavDock({ items, base, showThemeToggle }) {
|
|
14
|
+
export function NavDock({ items, base, showThemeToggle, idleReturn }) {
|
|
15
|
+
const appDefault = useIdleReturnDefault();
|
|
16
|
+
useIdleReturn({ to: mainPath(items, base), after: idleReturn ?? appDefault });
|
|
13
17
|
usePageInset('bottom', 88);
|
|
14
18
|
return (_jsxs(Flex, { as: "nav", "aria-label": "Pages", align: "center", gap: 1.5, background: "rail", radius: "full", p: 1.5, shadow: "dock", position: "fixed", css: ({ spacing }) => ({
|
|
15
19
|
bottom: spacing(5),
|
|
@@ -7,6 +7,8 @@ export interface NavItem {
|
|
|
7
7
|
/** A dot on the item for something on that page that needs a look: `notice` (warm) or `urgent` (red). Nothing by default. */
|
|
8
8
|
attention?: 'notice' | 'urgent';
|
|
9
9
|
}
|
|
10
|
+
/** The path of a dashboard's main page: the item with no path of its own, else its first item. */
|
|
11
|
+
export declare function mainPath(items: NavItem[], base: string): string;
|
|
10
12
|
export interface NavRailProps {
|
|
11
13
|
/** The pages to link to; `to` is relative to `base`. */
|
|
12
14
|
items: NavItem[];
|
|
@@ -14,8 +16,10 @@ export interface NavRailProps {
|
|
|
14
16
|
base: string;
|
|
15
17
|
/** Adds a light/dark toggle at the bottom of the rail. Off by default — meant for development or a project that deliberately exposes it, not every kiosk install. */
|
|
16
18
|
showThemeToggle?: boolean;
|
|
19
|
+
/** How long, in ms, the display may be left alone on a page other than the main one (the item whose `to` is `''`) before it goes back to that page; `false` for never. Any touch, click, key or scroll starts the time again. Defaults to `HashsomeProvider`'s `idleReturn`, which is off unless the app turns it on. */
|
|
20
|
+
idleReturn?: number | false;
|
|
17
21
|
}
|
|
18
22
|
/** Fixed left sidebar for switching between a dashboard's pages. Must be rendered inside a
|
|
19
23
|
* router. It is `position: fixed` and reserves its width in the surrounding `Page`, which pads
|
|
20
24
|
* for it so content never sits under it. */
|
|
21
|
-
export declare function NavRail({ items, base, showThemeToggle }: NavRailProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
25
|
+
export declare function NavRail({ items, base, showThemeToggle, idleReturn }: NavRailProps): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
@@ -7,10 +7,18 @@ import { usePageInset } from '../layout/page.js';
|
|
|
7
7
|
import { useThemeToggle } from '../provider.js';
|
|
8
8
|
import { NAV_RAIL } from '../theme/grid.js';
|
|
9
9
|
import { AttentionDot } from './attention-dot.js';
|
|
10
|
+
import { useIdleReturn, useIdleReturnDefault } from '../layout/use-idle-return.js';
|
|
11
|
+
/** The path of a dashboard's main page: the item with no path of its own, else its first item. */
|
|
12
|
+
export function mainPath(items, base) {
|
|
13
|
+
const main = items.find((item) => item.to === '') ?? items[0];
|
|
14
|
+
return main?.to ? `${base}/${main.to}` : base;
|
|
15
|
+
}
|
|
10
16
|
/** Fixed left sidebar for switching between a dashboard's pages. Must be rendered inside a
|
|
11
17
|
* router. It is `position: fixed` and reserves its width in the surrounding `Page`, which pads
|
|
12
18
|
* for it so content never sits under it. */
|
|
13
|
-
export function NavRail({ items, base, showThemeToggle }) {
|
|
19
|
+
export function NavRail({ items, base, showThemeToggle, idleReturn }) {
|
|
20
|
+
const appDefault = useIdleReturnDefault();
|
|
21
|
+
useIdleReturn({ to: mainPath(items, base), after: idleReturn ?? appDefault });
|
|
14
22
|
usePageInset('left', NAV_RAIL);
|
|
15
23
|
return (_jsxs(Flex, { as: "nav", "aria-label": "Pages", direction: "column", align: "center", gap: 2.5, background: "rail", width: NAV_RAIL, py: 6, position: "fixed", zIndex: "nav", css: { top: 0, bottom: 0, left: 0 }, children: [items.map((item) => (_jsx(NavLink, { to: item.to ? `${base}/${item.to}` : base, end: true, "aria-label": item.attention ? `${item.label}, needs attention` : item.label, title: item.label, children: ({ isActive }) => (_jsxs(Flex, { as: "span", align: "center", justify: "center", width: 44, height: 44, radius: "chrome", cursor: "pointer", position: "relative", color: isActive ? 'accentText' : 'line', ...(isActive ? { background: 'accent' } : {}), children: [_jsx(Icon, { name: item.icon, size: 22 }), item.attention ? _jsx(AttentionDot, { level: item.attention }) : null] })) }, item.to))), showThemeToggle ? _jsx(ThemeToggleButton, {}) : null] }));
|
|
16
24
|
}
|
|
@@ -824,6 +824,12 @@ export const COMPONENT_PROPS = {
|
|
|
824
824
|
optional: true,
|
|
825
825
|
type: 'boolean',
|
|
826
826
|
},
|
|
827
|
+
{
|
|
828
|
+
doc: "How long, in ms, the display may be left alone on a page other than the main one (the item whose `to` is `''`) before it goes back to that page; `false` for never. Any touch, click, key or scroll starts the time again. Defaults to `HashsomeProvider`'s `idleReturn`, which is off unless the app turns it on.",
|
|
829
|
+
name: 'idleReturn',
|
|
830
|
+
optional: true,
|
|
831
|
+
type: 'number | false',
|
|
832
|
+
},
|
|
827
833
|
],
|
|
828
834
|
},
|
|
829
835
|
NavDock: {
|
|
@@ -847,6 +853,12 @@ export const COMPONENT_PROPS = {
|
|
|
847
853
|
optional: true,
|
|
848
854
|
type: 'boolean',
|
|
849
855
|
},
|
|
856
|
+
{
|
|
857
|
+
doc: "How long, in ms, the display may be left alone on a page other than the main one (the item whose `to` is `''`) before it goes back to that page; `false` for never. Any touch, click, key or scroll starts the time again. Defaults to `HashsomeProvider`'s `idleReturn`, which is off unless the app turns it on.",
|
|
858
|
+
name: 'idleReturn',
|
|
859
|
+
optional: true,
|
|
860
|
+
type: 'number | false',
|
|
861
|
+
},
|
|
850
862
|
],
|
|
851
863
|
},
|
|
852
864
|
TopBar: {
|
|
@@ -1078,6 +1090,12 @@ export const COMPONENT_PROPS = {
|
|
|
1078
1090
|
optional: true,
|
|
1079
1091
|
type: 'EntityRef',
|
|
1080
1092
|
},
|
|
1093
|
+
{
|
|
1094
|
+
doc: "How long, in ms, a display is left alone on a dashboard's page other than its main one before it goes back to the main page, so a wall display does not stay on the music page for hours. Any touch, click, key or scroll starts the time again, and the main page itself is left alone. It is done by the dashboard's `NavRail` or `NavDock`, which can set their own `idleReturn` (a time, or `false`) over this. Default `false`: off.",
|
|
1095
|
+
name: 'idleReturn',
|
|
1096
|
+
optional: true,
|
|
1097
|
+
type: 'number | false',
|
|
1098
|
+
},
|
|
1081
1099
|
{
|
|
1082
1100
|
doc: 'The app.',
|
|
1083
1101
|
name: 'children',
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export * from './layout/page.tsx';
|
|
|
15
15
|
export * from './layout/animated-outlet.tsx';
|
|
16
16
|
export * from './layout/room-header.tsx';
|
|
17
17
|
export { Tile, type TileProps } from './layout/tile.tsx';
|
|
18
|
+
export { useIdleReturn } from './layout/use-idle-return.ts';
|
|
18
19
|
export { TaskTile, taskLine, type TaskState, type TaskTileProps } from './layout/task-tile.tsx';
|
|
19
20
|
export { ConfirmDialog, type ConfirmDialogProps } from './layout/confirm-dialog.tsx';
|
|
20
21
|
export * from './layout/energy-chart.tsx';
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ export * from './layout/page.js';
|
|
|
11
11
|
export * from './layout/animated-outlet.js';
|
|
12
12
|
export * from './layout/room-header.js';
|
|
13
13
|
export { Tile } from './layout/tile.js';
|
|
14
|
+
export { useIdleReturn } from './layout/use-idle-return.js';
|
|
14
15
|
export { TaskTile, taskLine } from './layout/task-tile.js';
|
|
15
16
|
export { ConfirmDialog } from './layout/confirm-dialog.js';
|
|
16
17
|
export * from './layout/energy-chart.js';
|
|
@@ -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,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** The app-wide time a display is left alone before it goes back to a dashboard's main page, set by
|
|
2
|
+
* `HashsomeProvider`'s `idleReturn`. `false`: it does not. */
|
|
3
|
+
export declare const IdleReturnContext: import("react").Context<number | false>;
|
|
4
|
+
/** Whether it is time to go back: nothing has been touched for `after`. */
|
|
5
|
+
export declare function shouldLeave(now: number, lastTouched: number, after: number): boolean;
|
|
6
|
+
/** The app's own setting for how long a display is left alone before it goes back to a main page: what
|
|
7
|
+
* `HashsomeProvider`'s `idleReturn` says, `false` when it says nothing. */
|
|
8
|
+
export declare function useIdleReturnDefault(): number | false;
|
|
9
|
+
/**
|
|
10
|
+
* Takes a wall display back to `to` (a dashboard's main page) once nobody has touched it for `after` ms,
|
|
11
|
+
* so that a page opened for a while (a music page, say) does not stay up for hours. Any touch, click, key
|
|
12
|
+
* or scroll anywhere starts the time again; it is counted from arriving on a page that is not `to`, and
|
|
13
|
+
* on `to` itself nothing happens. The page it leaves replaces itself in the history, so the back button does
|
|
14
|
+
* not return to it. `false` (or no time) turns it off.
|
|
15
|
+
*
|
|
16
|
+
* `NavRail` and `NavDock` do this for the dashboard they are in (see their `idleReturn`); use the hook
|
|
17
|
+
* directly for a dashboard that builds its own navigation.
|
|
18
|
+
*/
|
|
19
|
+
export declare function useIdleReturn({ to, after }: {
|
|
20
|
+
to: string;
|
|
21
|
+
after: number | false | undefined;
|
|
22
|
+
}): void;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useRef } from 'react';
|
|
2
|
+
import { useLocation, useNavigate } from 'react-router';
|
|
3
|
+
/** The app-wide time a display is left alone before it goes back to a dashboard's main page, set by
|
|
4
|
+
* `HashsomeProvider`'s `idleReturn`. `false`: it does not. */
|
|
5
|
+
export const IdleReturnContext = createContext(false);
|
|
6
|
+
/** How often the page checks whether it has been left alone long enough, at most. */
|
|
7
|
+
const CHECK_MS = 10_000;
|
|
8
|
+
/** What counts as someone being there: a touch or a click, a key, a scroll or a wheel anywhere on the page. */
|
|
9
|
+
const INTERACTIONS = [
|
|
10
|
+
'pointerdown',
|
|
11
|
+
'pointermove',
|
|
12
|
+
'keydown',
|
|
13
|
+
'wheel',
|
|
14
|
+
'touchstart',
|
|
15
|
+
'scroll',
|
|
16
|
+
];
|
|
17
|
+
/** Whether it is time to go back: nothing has been touched for `after`. */
|
|
18
|
+
export function shouldLeave(now, lastTouched, after) {
|
|
19
|
+
return now - lastTouched >= after;
|
|
20
|
+
}
|
|
21
|
+
/** The app's own setting for how long a display is left alone before it goes back to a main page: what
|
|
22
|
+
* `HashsomeProvider`'s `idleReturn` says, `false` when it says nothing. */
|
|
23
|
+
export function useIdleReturnDefault() {
|
|
24
|
+
return useContext(IdleReturnContext);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Takes a wall display back to `to` (a dashboard's main page) once nobody has touched it for `after` ms,
|
|
28
|
+
* so that a page opened for a while (a music page, say) does not stay up for hours. Any touch, click, key
|
|
29
|
+
* or scroll anywhere starts the time again; it is counted from arriving on a page that is not `to`, and
|
|
30
|
+
* on `to` itself nothing happens. The page it leaves replaces itself in the history, so the back button does
|
|
31
|
+
* not return to it. `false` (or no time) turns it off.
|
|
32
|
+
*
|
|
33
|
+
* `NavRail` and `NavDock` do this for the dashboard they are in (see their `idleReturn`); use the hook
|
|
34
|
+
* directly for a dashboard that builds its own navigation.
|
|
35
|
+
*/
|
|
36
|
+
export function useIdleReturn({ to, after }) {
|
|
37
|
+
const navigate = useNavigate();
|
|
38
|
+
const { pathname } = useLocation();
|
|
39
|
+
// Set when the time starts, in the effect below.
|
|
40
|
+
const lastTouched = useRef(0);
|
|
41
|
+
const enabled = typeof after === 'number' && after > 0;
|
|
42
|
+
const away = pathname.replace(/\/$/, '') !== to.replace(/\/$/, '');
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!enabled || !away) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
lastTouched.current = Date.now();
|
|
48
|
+
const touched = () => {
|
|
49
|
+
lastTouched.current = Date.now();
|
|
50
|
+
};
|
|
51
|
+
for (const name of INTERACTIONS) {
|
|
52
|
+
window.addEventListener(name, touched, { passive: true, capture: true });
|
|
53
|
+
}
|
|
54
|
+
const check = setInterval(() => {
|
|
55
|
+
if (shouldLeave(Date.now(), lastTouched.current, after)) {
|
|
56
|
+
navigate(to, { replace: true });
|
|
57
|
+
}
|
|
58
|
+
}, Math.min(CHECK_MS, after / 2));
|
|
59
|
+
return () => {
|
|
60
|
+
for (const name of INTERACTIONS) {
|
|
61
|
+
window.removeEventListener(name, touched, { capture: true });
|
|
62
|
+
}
|
|
63
|
+
clearInterval(check);
|
|
64
|
+
};
|
|
65
|
+
}, [enabled, away, after, to, navigate]);
|
|
66
|
+
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface HashsomeProviderProps {
|
|
|
29
29
|
debug?: boolean;
|
|
30
30
|
/** The daylight sensor (`on` while the sun is up) the debug menu's Sun theme follows, when `theme` is not a sun schedule already (`theme={{ sun: … }}` names one). Without either, the menu has no Sun choice: Hashsome does not know which entity is the sun. */
|
|
31
31
|
sun?: EntityRef;
|
|
32
|
+
/** How long, in ms, a display is left alone on a dashboard's page other than its main one before it goes back to the main page, so a wall display does not stay on the music page for hours. Any touch, click, key or scroll starts the time again, and the main page itself is left alone. It is done by the dashboard's `NavRail` or `NavDock`, which can set their own `idleReturn` (a time, or `false`) over this. Default `false`: off. */
|
|
33
|
+
idleReturn?: number | false;
|
|
32
34
|
/** The app. */
|
|
33
35
|
children: ReactNode;
|
|
34
36
|
}
|
|
@@ -44,5 +46,5 @@ export interface ThemeModeState {
|
|
|
44
46
|
* Must be rendered inside `<HashsomeProvider>`. */
|
|
45
47
|
export declare function useThemeToggle(): ThemeModeState;
|
|
46
48
|
/** Connects the tree to the runtime proxy. Render only on the client. */
|
|
47
|
-
export declare function HashsomeProvider({ url, client, clientOptions, theme, motion, font, density, overrides, debug, sun, children, }: HashsomeProviderProps): import("react").JSX.Element;
|
|
49
|
+
export declare function HashsomeProvider({ url, client, clientOptions, theme, motion, font, density, overrides, debug, sun, idleReturn, children, }: HashsomeProviderProps): import("react").JSX.Element;
|
|
48
50
|
export declare function useClient(): Client;
|
package/dist/provider.js
CHANGED
|
@@ -8,6 +8,7 @@ import { DebugContext, debugFromEnv, useDebugState, useFullscreenKept, } from '.
|
|
|
8
8
|
import { DebugMenu } from './layout/debug-menu.js';
|
|
9
9
|
import { DetailProvider } from './layout/detail-provider.js';
|
|
10
10
|
import { EntityDrawer } from './layout/entity-drawer.js';
|
|
11
|
+
import { IdleReturnContext } from './layout/use-idle-return.js';
|
|
11
12
|
import { UNITS_PER_SPACE } from './theme/density.js';
|
|
12
13
|
import { globalStyles } from './theme/global-styles.js';
|
|
13
14
|
import { applyThemeOverrides, densityTokens } from './theme/overrides.js';
|
|
@@ -195,7 +196,7 @@ function useThemeMode(mode, client) {
|
|
|
195
196
|
return { resolved, toggle: () => setOverride(resolved === 'dark' ? 'light' : 'dark') };
|
|
196
197
|
}
|
|
197
198
|
/** Connects the tree to the runtime proxy. Render only on the client. */
|
|
198
|
-
export function HashsomeProvider({ url, client, clientOptions, theme = 'dark', motion = 'auto', font = DEFAULT_FONT, density = 'comfortable', overrides, debug = debugFromEnv(), sun, children, }) {
|
|
199
|
+
export function HashsomeProvider({ url, client, clientOptions, theme = 'dark', motion = 'auto', font = DEFAULT_FONT, density = 'comfortable', overrides, debug = debugFromEnv(), sun, idleReturn = false, children, }) {
|
|
199
200
|
const instance = useMemo(() => client ?? new RemoteClient({ url: url ?? defaultUrl(), ...clientOptions }), [client, url, clientOptions]);
|
|
200
201
|
useMotionMode(motion);
|
|
201
202
|
const debugState = useDebugState();
|
|
@@ -215,7 +216,7 @@ export function HashsomeProvider({ url, client, clientOptions, theme = 'dark', m
|
|
|
215
216
|
instance.connect();
|
|
216
217
|
return () => instance.close();
|
|
217
218
|
}, [instance]);
|
|
218
|
-
return (_jsx(HashsomeContext.Provider, { value: instance, children: _jsx(ThemeModeContext.Provider, { value: themeMode, children: _jsx(ThemeProvider, { theme: resolvedTheme, children: _jsxs(EmotionThemeProvider, { theme: withDensity, children: [_jsx(Global, { styles: globalStyles }), _jsx(DebugContext.Provider, { value: debugState, children: _jsxs(DetailProvider, { children: [children, _jsx(EntityDrawer, {}), debug ? _jsx(DebugMenu, { configured: theme, sun: sunEntity }) : null] }) })] }) }) }) }));
|
|
219
|
+
return (_jsx(HashsomeContext.Provider, { value: instance, children: _jsx(ThemeModeContext.Provider, { value: themeMode, children: _jsx(ThemeProvider, { theme: resolvedTheme, children: _jsxs(EmotionThemeProvider, { theme: withDensity, children: [_jsx(Global, { styles: globalStyles }), _jsx(DebugContext.Provider, { value: debugState, children: _jsx(IdleReturnContext.Provider, { value: idleReturn, children: _jsxs(DetailProvider, { children: [children, _jsx(EntityDrawer, {}), debug ? _jsx(DebugMenu, { configured: theme, sun: sunEntity }) : null] }) }) })] }) }) }) }));
|
|
219
220
|
}
|
|
220
221
|
export function useClient() {
|
|
221
222
|
const client = useContext(HashsomeContext);
|
|
@@ -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
|
-
//
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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.
|
|
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.
|
|
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",
|