@hashsome/ui 0.8.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.
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { Box, Flex, Typography } from 'e-prim';
4
+ import { AnimatePresence, motion } from 'motion/react';
5
+ import { useEffect, useId, useRef } from 'react';
6
+ import { createPortal } from 'react-dom';
7
+ const FOCUSABLE = 'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])';
8
+ /**
9
+ * A question that has to be answered before something is done, so a stray tap cannot do it: a card in the
10
+ * middle of the page over a dimmed scrim, with a way to back out (Cancel, a tap outside, Escape) and a button
11
+ * to go ahead. It is drawn over the whole page, whatever it is opened from. Focus moves into it while it is
12
+ * open and returns to what had it after, and Tab stays inside.
13
+ */
14
+ export function ConfirmDialog({ open, title, description, confirmLabel = 'Confirm', cancelLabel = 'Cancel', onConfirm, onCancel, busy = false, children, }) {
15
+ const heading = useId();
16
+ const about = useId();
17
+ const card = useRef(null);
18
+ useEffect(() => {
19
+ if (!open) {
20
+ return;
21
+ }
22
+ const before = document.activeElement instanceof HTMLElement ? document.activeElement : null;
23
+ card.current?.focus();
24
+ return () => before?.focus();
25
+ }, [open]);
26
+ const onKeyDown = (event) => {
27
+ if (event.key === 'Escape') {
28
+ event.stopPropagation();
29
+ onCancel();
30
+ return;
31
+ }
32
+ if (event.key === 'Tab' && card.current) {
33
+ const inside = [...card.current.querySelectorAll(FOCUSABLE)];
34
+ const first = inside[0];
35
+ const last = inside.at(-1);
36
+ if (event.shiftKey &&
37
+ (document.activeElement === first || document.activeElement === card.current)) {
38
+ event.preventDefault();
39
+ last?.focus();
40
+ }
41
+ else if (!event.shiftKey && document.activeElement === last) {
42
+ event.preventDefault();
43
+ first?.focus();
44
+ }
45
+ }
46
+ };
47
+ return createPortal(_jsx(AnimatePresence, { children: open ? (_jsx(Flex, { as: motion.div, align: "center", justify: "center", position: "fixed", zIndex: "modal", p: 4, onClick: onCancel, initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.16 }, css: ({ palette }) => ({
48
+ inset: 0,
49
+ // The page's own color, so the dimming follows the theme.
50
+ background: `color-mix(in srgb, ${palette.bg} 72%, transparent)`,
51
+ }), children: _jsxs(Flex, { as: motion.div, ref: card, role: "dialog", "aria-modal": "true", "aria-labelledby": heading, ...(description ? { 'aria-describedby': about } : {}), tabIndex: -1, direction: "column", gap: 4, background: "surfaceRaised", radius: "card", shadow: "drawer", p: 5, onClick: (event) => event.stopPropagation(), onKeyDown: onKeyDown, initial: { opacity: 0, scale: 0.96, y: 8 }, animate: { opacity: 1, scale: 1, y: 0 }, exit: { opacity: 0, scale: 0.98 }, transition: { duration: 0.18, ease: 'easeOut' },
52
+ // Scrolls when what is in it does not fit the screen, rather than running off it.
53
+ css: {
54
+ width: 'min(420px, 100%)',
55
+ maxHeight: '100%',
56
+ overflowY: 'auto',
57
+ outline: 'none',
58
+ }, children: [_jsxs(Flex, { direction: "column", gap: 1.5, children: [_jsx(Typography, { as: "h2", variant: "heading", id: heading, children: title }), description ? (_jsx(Typography, { as: "p", variant: "body", color: "textMuted", id: about, children: description })) : null] }), children ? _jsx(Box, { children: children }) : null, _jsxs(Flex, { justify: "flex-end", gap: 2, children: [_jsx(DialogButton, { label: cancelLabel, disabled: busy, onClick: onCancel }), _jsx(DialogButton, { label: confirmLabel, primary: true, disabled: busy, onClick: onConfirm })] })] }) }, "scrim")) : null }), document.body);
59
+ }
60
+ function DialogButton({ label, primary = false, disabled, onClick, }) {
61
+ return (_jsx(Flex, { as: "button", type: "button", onClick: onClick, disabled: disabled, align: "center", justify: "center", radius: "full", cursor: disabled ? 'default' : 'pointer', height: 40, px: 5, background: primary ? 'accent' : 'surface', color: primary ? 'accentText' : 'text', css: { opacity: disabled ? 0.5 : 1 }, children: _jsx(Typography, { as: "span", variant: "label", children: label }) }));
62
+ }
@@ -5,6 +5,11 @@ import { THEME_CHOICES, useDebug } from '../debug.js';
5
5
  import { Icon } from '../icon.js';
6
6
  import { ChipRow } from './drawer-controls.js';
7
7
  import { PlainButton } from './plain-button.js';
8
+ import { NAV_RAIL } from '../theme/grid.js';
9
+ /** The button's size, in px. */
10
+ const BUTTON = 40;
11
+ /** How far it is from the left and the bottom edge: on the line the navigation rail's icons are centered on, so it sits under them, and the same from the bottom. */
12
+ const INSET = (NAV_RAIL - BUTTON) / 2;
8
13
  const THEME_LABELS = {
9
14
  light: 'Light',
10
15
  dark: 'Dark',
@@ -85,15 +90,15 @@ export function DebugMenu({ configured, sun, }) {
85
90
  // which this is, and the button would sit at the top of it.
86
91
  css: {
87
92
  position: 'fixed',
88
- left: 12,
89
- bottom: 12,
93
+ left: INSET,
94
+ bottom: INSET,
90
95
  zIndex: 30,
91
- width: 40,
92
- height: 40,
96
+ width: BUTTON,
97
+ height: BUTTON,
93
98
  minHeight: 0,
94
99
  }, children: [open ? (_jsxs(Flex, { role: "dialog", "aria-label": "Debug", direction: "column", gap: 4, background: "surface", radius: "row", shadow: "drawer", p: 4, width: 288, css: { position: 'absolute', left: 0, bottom: 52, boxSizing: 'border-box' }, children: [_jsx(Row, { label: "Show grid", children: _jsx(ChipRow, { options: ON_OFF, value: debug.grid ? 'on' : 'off', onChange: (value) => debug.setGrid(value === 'on') }) }), _jsx(Row, { label: "Fullscreen", children: canFullscreen ? (_jsx(ChipRow, { options: ON_OFF, value: isFull ? 'on' : 'off', onChange: (value) => toggleFullscreen(value === 'on') })) : (_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: "Not available in this browser." })) }), _jsx(Row, { label: "Theme", children: _jsx(ChipRow
95
100
  // The sun only if the project has one to follow.
96
101
  , {
97
102
  // The sun only if the project has one to follow.
98
- options: THEME_CHOICES.filter((choice) => choice !== 'sun' || sun !== undefined).map((choice) => ({ value: choice, label: THEME_LABELS[choice] })), value: theme, onChange: (value) => debug.setThemeChoice(value) }) })] })) : null, _jsx(PlainButton, { "aria-label": "Debug menu", title: "Debug menu", "aria-expanded": open, onClick: () => setOpen((current) => !current), center: true, radius: "full", background: "surface", color: "textMuted", shadow: "dock", width: 40, height: 40, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:bug", size: 18 }) })] }));
103
+ options: THEME_CHOICES.filter((choice) => choice !== 'sun' || sun !== undefined).map((choice) => ({ value: choice, label: THEME_LABELS[choice] })), value: theme, onChange: (value) => debug.setThemeChoice(value) }) })] })) : null, _jsx(PlainButton, { "aria-label": "Debug menu", title: "Debug menu", "aria-expanded": open, onClick: () => setOpen((current) => !current), center: true, radius: "full", background: "surface", color: "textMuted", shadow: "dock", width: BUTTON, height: BUTTON, css: { flex: 'none' }, children: _jsx(Icon, { name: "lu:bug", size: 18 }) })] }));
99
104
  }
@@ -1,3 +1,4 @@
1
+ import { type PaletteKey } from 'e-prim';
1
2
  /** A turning ring: something is in progress and will not say how long it takes. Kept turning when the
2
3
  * app is set to reduced motion (`data-keep-motion`): a spinner that stopped would look stuck, and one
3
4
  * small turning icon costs next to nothing. */
@@ -8,6 +9,8 @@ export declare function Spinner({ size, label }: {
8
9
  /** A ring that turns around the edge of a round button (give the button `position: relative`): the
9
10
  * button is waiting for an answer and will not take another press. Kept turning under reduced motion
10
11
  * for the same reason `Spinner` is. */
11
- export declare function SpinnerRing({ label }: {
12
+ export declare function SpinnerRing({ label, color, }: {
12
13
  label?: string;
14
+ /** A palette color: the theme's accent by default. Pass `accentText` for a button that is itself accent-colored (or filled with a color of the app's own). */
15
+ color?: PaletteKey;
13
16
  }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
2
  /** @jsxImportSource @emotion/react */
3
3
  import { keyframes } from '@emotion/react';
4
+ import { Box } from 'e-prim';
4
5
  import { Icon } from '../icon.js';
5
6
  const turn = keyframes({ to: { transform: 'rotate(360deg)' } });
6
7
  /** A turning ring: something is in progress and will not say how long it takes. Kept turning when the
@@ -17,12 +18,6 @@ export function Spinner({ size = 16, label = 'Loading' }) {
17
18
  /** A ring that turns around the edge of a round button (give the button `position: relative`): the
18
19
  * button is waiting for an answer and will not take another press. Kept turning under reduced motion
19
20
  * for the same reason `Spinner` is. */
20
- export function SpinnerRing({ label = 'Loading' }) {
21
- return (_jsx("span", { role: "status", "aria-label": label, "data-keep-motion": true, css: ({ palette }) => ({
22
- position: 'absolute',
23
- inset: 0,
24
- pointerEvents: 'none',
25
- color: palette.accent,
26
- animation: `${turn} 0.9s linear infinite`,
27
- }), children: _jsx("svg", { viewBox: "0 0 100 100", width: "100%", height: "100%", "aria-hidden": "true", children: _jsx("circle", { cx: "50", cy: "50", r: "47", fill: "none", stroke: "currentColor", strokeWidth: "5", strokeLinecap: "round", strokeDasharray: "75 221" }) }) }));
21
+ export function SpinnerRing({ label = 'Loading', color = 'accent', }) {
22
+ return (_jsx(Box, { as: "span", role: "status", "aria-label": label, "data-keep-motion": true, position: "absolute", color: color, css: { inset: 0, pointerEvents: 'none', animation: `${turn} 0.9s linear infinite` }, children: _jsx("svg", { viewBox: "0 0 100 100", width: "100%", height: "100%", "aria-hidden": "true", children: _jsx("circle", { cx: "50", cy: "50", r: "47", fill: "none", stroke: "currentColor", strokeWidth: "5", strokeLinecap: "round", strokeDasharray: "75 221" }) }) }));
28
23
  }
@@ -0,0 +1,28 @@
1
+ import type { IconName } from '../icon-data.ts';
2
+ import type { EntityStatus } from '../status.ts';
3
+ /** Where a recurring task stands: not due for a while, due before long, or past its date. */
4
+ export type TaskState = 'ok' | 'dueSoon' | 'overdue';
5
+ export interface TaskTileProps {
6
+ /** What is to be done, e.g. `Septic additive`. */
7
+ label: string;
8
+ /** Icon id, e.g. `lu:wrench`. */
9
+ icon?: IconName;
10
+ /** Where the task stands. Whoever has the task decides it: how soon is "soon" is different for a weekly task and a yearly one. */
11
+ state: TaskState;
12
+ /** When it is next due. Shown as "Due in 2 days" or "Overdue by 3 days". */
13
+ dueAt?: Date | undefined;
14
+ /** When it was last done, shown in the question that marks it done again. */
15
+ lastDoneAt?: Date | undefined;
16
+ /** Status of the thing behind it, as for `Tile`: anything but `ready` shows why instead and disables the tile. */
17
+ status?: EntityStatus;
18
+ /** The person confirmed it is done, on this day (today unless they chose another, in case it was forgotten at the time). Rejecting shows the error in the dialog and leaves it open. */
19
+ onComplete: (doneOn: Date) => void | Promise<void>;
20
+ }
21
+ /** The line under a task's name. */
22
+ export declare function taskLine(state: TaskState, dueAt: Date | undefined, now: Date): string;
23
+ /**
24
+ * A recurring task as a tile: its name, where it stands (up to date, due soon, overdue) and when it is due.
25
+ * Tapping it asks whether it is done, with the day it was done on (today, or an earlier one if it was
26
+ * forgotten), and only a confirmation calls `onComplete`, so a stray tap does nothing.
27
+ */
28
+ export declare function TaskTile({ label, icon, state, dueAt, lastDoneAt, status, onComplete, }: TaskTileProps): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,78 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { Flex, Typography } from 'e-prim';
4
+ import { useEffect, useState } from 'react';
5
+ import { ConfirmDialog } from './confirm-dialog.js';
6
+ import { IconButton, Tile } from './tile.js';
7
+ const DAY_MS = 86_400_000;
8
+ const startOfDay = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
9
+ /** How many calendar days from `now` to `then`: negative when `then` has passed. */
10
+ const daysTo = (then, now) => Math.round((startOfDay(then).getTime() - startOfDay(now).getTime()) / DAY_MS);
11
+ const plural = (n) => `${n} day${n === 1 ? '' : 's'}`;
12
+ const short = (date) => date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
13
+ /** The line under a task's name. */
14
+ export function taskLine(state, dueAt, now) {
15
+ if (!dueAt) {
16
+ return state === 'overdue' ? 'Overdue' : state === 'dueSoon' ? 'Due soon' : 'Up to date';
17
+ }
18
+ const days = daysTo(dueAt, now);
19
+ if (state === 'ok') {
20
+ return `Next ${short(dueAt)}`;
21
+ }
22
+ if (days === 0) {
23
+ return 'Due today';
24
+ }
25
+ if (days > 0) {
26
+ return days === 1 ? 'Due tomorrow' : `Due in ${plural(days)}`;
27
+ }
28
+ return `Overdue by ${plural(-days)}`;
29
+ }
30
+ /** The palette color of each state's line. */
31
+ const TONE = { ok: 'textMuted', dueSoon: 'warm', overdue: 'danger' };
32
+ /**
33
+ * A recurring task as a tile: its name, where it stands (up to date, due soon, overdue) and when it is due.
34
+ * Tapping it asks whether it is done, with the day it was done on (today, or an earlier one if it was
35
+ * forgotten), and only a confirmation calls `onComplete`, so a stray tap does nothing.
36
+ */
37
+ export function TaskTile({ label, icon, state, dueAt, lastDoneAt, status = 'ready', onComplete, }) {
38
+ const [open, setOpen] = useState(false);
39
+ // How many days before today it was done: 0 is today.
40
+ const [daysAgo, setDaysAgo] = useState(0);
41
+ const [busy, setBusy] = useState(false);
42
+ const [error, setError] = useState();
43
+ // The words under the name follow the clock: "Due tomorrow" becomes "Due today" at midnight.
44
+ const [now, setNow] = useState(() => new Date());
45
+ useEffect(() => {
46
+ const id = setInterval(() => setNow(new Date()), 60_000);
47
+ return () => clearInterval(id);
48
+ }, []);
49
+ const ask = () => {
50
+ setDaysAgo(0);
51
+ setError(undefined);
52
+ setOpen(true);
53
+ };
54
+ const confirm = async () => {
55
+ setBusy(true);
56
+ try {
57
+ await onComplete(daysAgo === 0 ? new Date() : startOfDay(new Date(now.getTime() - daysAgo * DAY_MS)));
58
+ setOpen(false);
59
+ }
60
+ catch (failure) {
61
+ setError(failure instanceof Error ? failure.message : String(failure));
62
+ }
63
+ finally {
64
+ setBusy(false);
65
+ }
66
+ };
67
+ return (_jsxs(_Fragment, { children: [_jsx(Tile, { label: label, ...(icon ? { icon } : {}), status: status, onPress: ask, secondary: _jsx(Typography, { as: "span", variant: "secondary", css: ({ colorByKey }) => ({ color: colorByKey(TONE[state]) }), children: taskLine(state, dueAt, now) }) }), _jsx(ConfirmDialog, { open: open, title: `Mark “${label}” as done?`, description: lastDoneAt ? `Last done ${short(lastDoneAt)}.` : undefined, confirmLabel: "Mark as done", busy: busy, onConfirm: () => void confirm(), onCancel: () => setOpen(false), children: _jsxs(Flex, { direction: "column", gap: 2, children: [_jsx(DayStepper, { label: "Done on", daysAgo: daysAgo, now: now, onChange: setDaysAgo }), error ? (_jsx(Typography, { as: "span", variant: "secondary", color: "danger", role: "alert", children: error })) : null] }) })] }));
68
+ }
69
+ /** A day chosen by stepping back from today one at a time, which is what is wanted for a task that was done a day or two ago and is quicker, and far smaller, than a calendar. It cannot go past today. */
70
+ function DayStepper({ label, daysAgo, now, onChange, }) {
71
+ const day = new Date(now.getTime() - daysAgo * DAY_MS);
72
+ const words = daysAgo === 0
73
+ ? 'Today'
74
+ : daysAgo === 1
75
+ ? 'Yesterday'
76
+ : day.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' });
77
+ return (_jsxs(Flex, { align: "center", justify: "space-between", gap: 3, role: "group", "aria-label": label, children: [_jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: label }), _jsxs(Flex, { align: "center", gap: 2, children: [_jsx(IconButton, { icon: "lu:chevron-left", label: "Previous day", glyph: 16, onClick: () => onChange(daysAgo + 1) }), _jsx(Typography, { as: "span", variant: "bodyStrong", css: { minWidth: 96, textAlign: 'center' }, children: words }), _jsx(IconButton, { icon: "lu:chevron-right", label: "Next day", glyph: 16, disabled: daysAgo === 0, onClick: () => onChange(daysAgo - 1) })] })] }));
78
+ }
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ /** Whether something that plays for a fixed time is playing. `start` begins it, and it is over `ms`
2
+ * later whatever else happens; starting it while it plays does nothing, so a press cannot be repeated until
3
+ * the animation it set off is over. */
4
+ export declare function useTimed(ms: number): {
5
+ active: boolean;
6
+ start: () => boolean;
7
+ };
@@ -0,0 +1,22 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ /** Whether something that plays for a fixed time is playing. `start` begins it, and it is over `ms`
3
+ * later whatever else happens; starting it while it plays does nothing, so a press cannot be repeated until
4
+ * the animation it set off is over. */
5
+ export function useTimed(ms) {
6
+ const [active, setActive] = useState(false);
7
+ const timer = useRef(undefined);
8
+ // Cleared with the component: a timer left running would set state on what is gone.
9
+ useEffect(() => () => clearTimeout(timer.current), []);
10
+ const start = () => {
11
+ if (timer.current !== undefined) {
12
+ return false;
13
+ }
14
+ setActive(true);
15
+ timer.current = setTimeout(() => {
16
+ timer.current = undefined;
17
+ setActive(false);
18
+ }, ms);
19
+ return true;
20
+ };
21
+ return { active, start };
22
+ }
@@ -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);
@@ -28,3 +28,6 @@ export declare function centeringOffsets(available: number, module: number): {
28
28
  top: number;
29
29
  bottom: number;
30
30
  };
31
+ /** The width of the page navigation rail (`NavRail`), whose icons are centered in it. Things that sit at the
32
+ * left edge of the screen over a dashboard (the debug button) are centered on the same line. */
33
+ export declare const NAV_RAIL = 76;
@@ -36,3 +36,6 @@ export function centeringOffsets(available, module) {
36
36
  const top = Math.floor(leftover / 2);
37
37
  return { top, bottom: leftover - top };
38
38
  }
39
+ /** The width of the page navigation rail (`NavRail`), whose icons are centered in it. Things that sit at the
40
+ * left edge of the screen over a dashboard (the debug button) are centered on the same line. */
41
+ export const NAV_RAIL = 76;
@@ -35,6 +35,7 @@ const shared = {
35
35
  scrim: 20,
36
36
  drawer: 21,
37
37
  popover: 30,
38
+ modal: 40,
38
39
  },
39
40
  shadow: {
40
41
  drawer: '0 20px 48px rgba(0, 0, 0, 0.5)',
@@ -81,6 +82,7 @@ export const darkTheme = {
81
82
  onAccent: '#1B1B1F',
82
83
  warm: '#E3B341',
83
84
  danger: '#F2554A',
85
+ success: '#34D399',
84
86
  line: '#726C6A',
85
87
  rail: '#141216',
86
88
  border: 'rgba(255, 255, 255, 0.09)',
@@ -100,6 +102,7 @@ export const lightTheme = {
100
102
  onAccent: '#1B1B1F',
101
103
  warm: '#E3B341',
102
104
  danger: '#F2554A',
105
+ success: '#34D399',
103
106
  line: '#4A4540',
104
107
  rail: '#EAE4DA',
105
108
  border: 'rgba(0, 0, 0, 0.08)',
@@ -20,6 +20,8 @@ declare module 'e-prim' {
20
20
  onAccent: string;
21
21
  warm: string;
22
22
  danger: string;
23
+ /** Something fine or connected (a status dot, a healthy link). */
24
+ success: string;
23
25
  line: string;
24
26
  /** The left nav rail / bottom nav dock's background — a third near-black, distinct from
25
27
  * `bg`/`surface`. */
@@ -54,6 +56,8 @@ declare module 'e-prim' {
54
56
  dropdown: number;
55
57
  /** A floating panel anchored to a control (the date picker's calendar), above what it sits in. */
56
58
  popover: number;
59
+ /** A dialog (`ConfirmDialog`) and the scrim behind it, over everything else on the page. */
60
+ modal: number;
57
61
  }
58
62
  interface TTypography {
59
63
  /** A tile's own name (`Tile`'s label). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashsome/ui",
3
- "version": "0.8.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.8.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",