@hashsome/ui 0.9.0 → 0.10.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.
@@ -1,4 +1,4 @@
1
- import type { NavItem } from './nav-rail.tsx';
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';
@@ -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
+ }
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashsome/ui",
3
- "version": "0.9.0",
3
+ "version": "0.10.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.9.0",
19
+ "@hashsome/core": "0.10.0",
20
20
  "e-prim": "2.0.1",
21
21
  "motion": "13.4.5",
22
22
  "react": "19.3.0",