@hashsome/ui 0.7.0 → 0.9.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.
Files changed (46) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/entities/attention-dot.d.ts +4 -0
  3. package/dist/entities/attention-dot.js +12 -0
  4. package/dist/entities/media-browser.js +35 -9
  5. package/dist/entities/media-controls.d.ts +22 -4
  6. package/dist/entities/media-controls.js +45 -10
  7. package/dist/entities/media-loading-line.d.ts +9 -0
  8. package/dist/entities/media-loading-line.js +14 -0
  9. package/dist/entities/media-pending.d.ts +24 -0
  10. package/dist/entities/media-pending.js +121 -0
  11. package/dist/entities/media-player-bar.js +10 -6
  12. package/dist/entities/media-progress.d.ts +4 -0
  13. package/dist/entities/media-progress.js +41 -0
  14. package/dist/entities/nav-dock.js +2 -1
  15. package/dist/entities/nav-rail.d.ts +2 -0
  16. package/dist/entities/nav-rail.js +4 -2
  17. package/dist/entities/now-playing.js +9 -6
  18. package/dist/entities/top-bar.d.ts +1 -1
  19. package/dist/entities/top-bar.js +24 -11
  20. package/dist/gallery/gallery.js +27 -1
  21. package/dist/gallery/props-data.js +106 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +2 -0
  24. package/dist/layout/check-mark.d.ts +5 -0
  25. package/dist/layout/check-mark.js +9 -0
  26. package/dist/layout/confirm-dialog.d.ts +28 -0
  27. package/dist/layout/confirm-dialog.js +62 -0
  28. package/dist/layout/debug-menu.js +10 -5
  29. package/dist/layout/drawer-controls.js +46 -3
  30. package/dist/layout/fade-scroll.d.ts +2 -0
  31. package/dist/layout/fade-scroll.js +1 -1
  32. package/dist/layout/icon-button-styles.js +3 -1
  33. package/dist/layout/spinner.d.ts +16 -0
  34. package/dist/layout/spinner.js +23 -0
  35. package/dist/layout/task-tile.d.ts +28 -0
  36. package/dist/layout/task-tile.js +78 -0
  37. package/dist/layout/tile.d.ts +1 -1
  38. package/dist/layout/tile.js +3 -1
  39. package/dist/layout/use-timed.d.ts +7 -0
  40. package/dist/layout/use-timed.js +22 -0
  41. package/dist/theme/grid.d.ts +3 -0
  42. package/dist/theme/grid.js +3 -0
  43. package/dist/theme/index.js +3 -0
  44. package/dist/theme/tokens.d.ts +4 -0
  45. package/dist/use-media-queue.js +5 -2
  46. package/package.json +2 -2
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
2
  /** @jsxImportSource @emotion/react */
3
3
  import { Box, Flex, Typography } from 'e-prim';
4
- import { motion } from 'motion/react';
4
+ import { animate, motion } from 'motion/react';
5
5
  import { MarqueeText } from './marquee-text.js';
6
6
  import { useEffect, useId, useRef, } from 'react';
7
7
  import { Icon } from '../icon.js';
8
- import { FadeScroll } from './fade-scroll.js';
8
+ import { FADE, FadeScroll } from './fade-scroll.js';
9
9
  import { RoundButton } from './round-button.js';
10
10
  /** Fine adjustment: a press nudges by `delta` (1%); holding repeats in steps of 10 until released. */
11
11
  export function StepButton({ icon, label, delta, holdDelta = delta * 10, onStep, disabled, size = 36, }) {
@@ -104,6 +104,49 @@ export function ChipRow({ options, value, onChange, tabs = false, }) {
104
104
  // The accent behind the selected chip is one pill that glides to the next; its own id, so two rows
105
105
  // on a page do not share it.
106
106
  const pill = useId();
107
+ // The selected tab is brought into view when it is not (it is a long name that has just opened, or a
108
+ // tab part way out of the row), by scrolling the row so it is at its start.
109
+ const marker = useRef(null);
110
+ const selectedLabel = options.find((option) => option.value === value)?.label;
111
+ useEffect(() => {
112
+ const chip = marker.current?.parentElement;
113
+ const scroller = chip?.parentElement;
114
+ if (!tabs || !chip || !scroller) {
115
+ return;
116
+ }
117
+ let scroll;
118
+ const reveal = () => {
119
+ const at = chip.getBoundingClientRect();
120
+ const row = scroller.getBoundingClientRect();
121
+ // Out of view includes under the fade at an edge that has more beyond it, which hides it nearly as much.
122
+ const inLeft = row.left + (scroller.scrollLeft > 1 ? FADE : 0);
123
+ const more = scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1;
124
+ const inRight = row.right - (more ? FADE : 0);
125
+ if (at.left >= inLeft && at.right <= inRight) {
126
+ return;
127
+ }
128
+ scroll?.stop();
129
+ const from = scroller.scrollLeft;
130
+ // To the start of the row, clear of the fade there (a position before the start is the start).
131
+ scroll = animate(from, from + (at.left - row.left) - FADE, {
132
+ duration: 0.35,
133
+ ease: 'easeOut',
134
+ onUpdate: (to) => {
135
+ scroller.scrollLeft = to;
136
+ },
137
+ });
138
+ };
139
+ reveal();
140
+ // The row can get narrower after the tab is selected (the actions of what opened come in beside it).
141
+ const resize = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(reveal);
142
+ resize?.observe(scroller);
143
+ return () => {
144
+ scroll?.stop();
145
+ resize?.disconnect();
146
+ };
147
+ // `selectedLabel` is not read inside: a new name for the selected tab is its length changing, which is a reason to look again.
148
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
149
+ }, [tabs, value, selectedLabel]);
107
150
  // Tabs scroll sideways when there are more than fit, and fade at the edge that has more.
108
151
  const Wrap = tabs ? FadeScroll : Flex;
109
152
  return (_jsx(Wrap, { gap: 2, ...(tabs ? { role: 'tablist' } : {}), css: tabs
@@ -123,7 +166,7 @@ export function ChipRow({ options, value, onChange, tabs = false, }) {
123
166
  position: 'relative',
124
167
  whiteSpace: 'nowrap',
125
168
  transition: 'color 160ms ease',
126
- }, children: [selected ? (_jsx(Box, { as: motion.span, layoutId: pill, position: "absolute", radius: "full", background: "accent", transition: { type: 'spring', duration: 0.32, bounce: 0.12 },
169
+ }, children: [selected ? _jsx("span", { ref: marker, hidden: true }) : null, selected ? (_jsx(Box, { as: motion.span, layoutId: pill, position: "absolute", radius: "full", background: "accent", transition: { type: 'spring', duration: 0.32, bounce: 0.12 },
127
170
  // Above every chip's own background, whichever way it is going: a later chip paints over
128
171
  // an earlier one's pill otherwise.
129
172
  css: { inset: 0, zIndex: 1 } })) : null, _jsxs(Flex, { as: "span", align: "center", gap: 1.5, position: "relative", minWidth: 0, css: { zIndex: 2 }, children: [option.icon ? _jsx(Icon, { name: option.icon, size: 14 }) : null, _jsx(Typography, { as: "span", variant: "body", css: { minWidth: 0, maxWidth: 220 }, children: _jsx(MarqueeText, { children: option.label }) })] })] }, option.value));
@@ -2,6 +2,8 @@
2
2
  import type { Interpolation, Theme } from '@emotion/react';
3
3
  import { type FlexProps } from 'e-prim';
4
4
  import { type ElementType } from 'react';
5
+ /** How wide the fade at a scrolled edge is. */
6
+ export declare const FADE = 40;
5
7
  /**
6
8
  * A row that scrolls sideways and fades out at an edge that has more content beyond it, so it
7
9
  * reads as scrollable: the right edge while there is more to the right, the left once scrolled.
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
2
  import { Flex } from 'e-prim';
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
4
  /** How wide the fade at a scrolled edge is. */
5
- const FADE = 40;
5
+ export const FADE = 40;
6
6
  const maskFor = (start, end) => `linear-gradient(to right, ${start ? 'transparent' : '#000'} 0, #000 ${start ? FADE : 0}px, #000 calc(100% - ${end ? FADE : 0}px), ${end ? 'transparent' : '#000'} 100%)`;
7
7
  /**
8
8
  * A row that scrolls sideways and fades out at an edge that has more content beyond it, so it
@@ -16,7 +16,9 @@ export const iconButtonStyles = ({ palette, radius }) => ({
16
16
  transition: 'background-color 0.2s ease, color 0.2s ease, opacity 0.2s ease',
17
17
  '&:disabled': { cursor: 'default', opacity: 0.5 },
18
18
  "&[data-active='true']": { color: palette.warm },
19
- "&[data-feedback='pending']": { opacity: 0.6 },
19
+ // Waiting: the ring turning around the edge says so, so the button stays as clear as it was.
20
+ "&[data-feedback='pending']": { position: 'relative', cursor: 'progress' },
21
+ "&:disabled[data-feedback='pending']": { cursor: 'progress', opacity: 1 },
20
22
  "&[data-feedback='done']": { background: palette.accent, color: palette.accentText },
21
23
  "&[data-feedback='error']": { color: palette.danger },
22
24
  "&[data-primary='true']": { background: palette.accent, color: palette.accentText },
@@ -0,0 +1,16 @@
1
+ import { type PaletteKey } from 'e-prim';
2
+ /** A turning ring: something is in progress and will not say how long it takes. Kept turning when the
3
+ * app is set to reduced motion (`data-keep-motion`): a spinner that stopped would look stuck, and one
4
+ * small turning icon costs next to nothing. */
5
+ export declare function Spinner({ size, label }: {
6
+ size?: number;
7
+ label?: string;
8
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
9
+ /** A ring that turns around the edge of a round button (give the button `position: relative`): the
10
+ * button is waiting for an answer and will not take another press. Kept turning under reduced motion
11
+ * for the same reason `Spinner` is. */
12
+ export declare function SpinnerRing({ label, color, }: {
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;
16
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { keyframes } from '@emotion/react';
4
+ import { Box } from 'e-prim';
5
+ import { Icon } from '../icon.js';
6
+ const turn = keyframes({ to: { transform: 'rotate(360deg)' } });
7
+ /** A turning ring: something is in progress and will not say how long it takes. Kept turning when the
8
+ * app is set to reduced motion (`data-keep-motion`): a spinner that stopped would look stuck, and one
9
+ * small turning icon costs next to nothing. */
10
+ export function Spinner({ size = 16, label = 'Loading' }) {
11
+ return (_jsx("span", { role: "status", "aria-label": label, "data-keep-motion": true, css: {
12
+ display: 'inline-grid',
13
+ placeItems: 'center',
14
+ flex: 'none',
15
+ animation: `${turn} 0.9s linear infinite`,
16
+ }, children: _jsx(Icon, { name: "lu:loader-circle", size: size }) }));
17
+ }
18
+ /** A ring that turns around the edge of a round button (give the button `position: relative`): the
19
+ * button is waiting for an answer and will not take another press. Kept turning under reduced motion
20
+ * for the same reason `Spinner` is. */
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" }) }) }));
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
+ }
@@ -70,6 +70,6 @@ export declare function IconButton({ icon, label, onClick, disabled, active, pre
70
70
  color?: string;
71
71
  /** For a toggle: whether it is on, announced to assistive tech (`aria-pressed`). Pair it with `active` to color it. */
72
72
  pressed?: boolean;
73
- /** Shows the outcome of an action the press started: `pending` dims it, `done` flashes it with the accent, `error` turns it red. Every press also gives a small squeeze. */
73
+ /** Shows the outcome of an action the press started: `pending` turns a ring around it and stops it taking another press, `done` flashes it with the accent, `error` turns it red. Every press also gives a small squeeze. */
74
74
  feedback?: 'pending' | 'done' | 'error' | undefined;
75
75
  }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -4,6 +4,7 @@ import { AnimatePresence, motion } from 'motion/react';
4
4
  import { useEffect, useMemo, useRef, useState, } from 'react';
5
5
  import { Icon } from '../icon.js';
6
6
  import { RoundButton } from './round-button.js';
7
+ import { SpinnerRing } from './spinner.js';
7
8
  import { statusLabels } from '../status.js';
8
9
  import { BADGE } from '../theme/grid.js';
9
10
  import { useDrawer } from './use-drawer.js';
@@ -211,5 +212,6 @@ export function Tile({ label, icon, secondary, status = 'ready', active = false,
211
212
  }, children: [iconBadge, _jsx(Typography, { variant: "label", as: "span", noWrap: true, textOverflow: "ellipsis", position: "relative", grow: 1, minWidth: 0, children: label }), !ready ? (_jsx(Typography, { variant: "secondary", as: "span", noWrap: true, textOverflow: "ellipsis", color: "textMuted", position: "relative", css: { flex: 'none' }, children: statusLabels[status] })) : liveSecondary ? (_jsx(Typography, { variant: "secondary", as: "span", noWrap: true, textOverflow: "ellipsis", color: statusOnAccent ? 'onAccent' : dimColorKey, position: "relative", css: { flex: 'none' }, children: liveSecondary })) : null] })), trailing ? (_jsx(Flex, { align: "center", gap: 2, pr: 2.5, position: "relative", children: trailing })) : null] }));
212
213
  }
213
214
  export function IconButton({ icon, label, onClick, disabled, active, pressed, primary, size, glyph, color, feedback, }) {
214
- return (_jsx(RoundButton, { as: motion.button, ...(size !== undefined ? { size: size * 2 } : {}), ...(color ? { css: { '&&&': { color } } } : {}), whileTap: disabled ? {} : { scale: 0.88 }, transition: { duration: 0.12 }, "data-feedback": feedback, "aria-label": label, title: label, disabled: disabled, "data-active": active, "aria-pressed": pressed, "data-primary": primary, onClick: onClick, children: _jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsx(Box, { as: motion.span, css: { display: 'grid' }, initial: { rotate: -90, opacity: 0, scale: 0.6 }, animate: { rotate: 0, opacity: 1, scale: 1 }, exit: { rotate: 90, opacity: 0, scale: 0.6 }, transition: { duration: 0.18 }, children: _jsx(Icon, { name: icon, ...(glyph !== undefined ? { size: glyph } : size !== undefined ? { size } : {}) }) }, icon) }) }));
215
+ const ring = feedback === 'pending';
216
+ return (_jsxs(RoundButton, { as: motion.button, ...(size !== undefined ? { size: size * 2 } : {}), ...(color ? { css: { '&&&': { color } } } : {}), whileTap: disabled || feedback === 'pending' ? {} : { scale: 0.88 }, transition: { duration: 0.12 }, "data-feedback": feedback, "aria-label": label, title: label, disabled: disabled || feedback === 'pending', "data-active": active, "aria-pressed": pressed, "data-primary": primary, onClick: onClick, children: [_jsx(AnimatePresence, { initial: false, mode: "popLayout", children: _jsx(Box, { as: motion.span, css: { display: 'grid' }, initial: { rotate: -90, opacity: 0, scale: 0.6 }, animate: { rotate: 0, opacity: 1, scale: 1 }, exit: { rotate: 90, opacity: 0, scale: 0.6 }, transition: { duration: 0.18 }, children: _jsx(Icon, { name: icon, ...(glyph !== undefined ? { size: glyph } : size !== undefined ? { size } : {}) }) }, icon) }), ring ? _jsx(SpinnerRing, {}) : null] }));
215
217
  }
@@ -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
+ }
@@ -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). */
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useState } from 'react';
2
2
  import { useEntity, useReconnects } from './hooks.js';
3
+ import { useMediaPending } from './entities/media-pending.js';
3
4
  import { useClient } from './provider.js';
4
5
  /** How often the queue is read again while it is on screen, to pick up what another app changed. */
5
6
  const POLL_MS = 15_000;
@@ -19,6 +20,8 @@ export function useMediaQueue(ref) {
19
20
  // What changes when the queue's window does: the track playing and the order it plays in.
20
21
  const track = player?.kind === 'mediaPlayer' ? player.media?.title : undefined;
21
22
  const shuffle = player?.kind === 'mediaPlayer' ? player.shuffle : undefined;
23
+ // Something added to or played from the library changes the queue without changing the track.
24
+ const asked = useMediaPending(ref).length;
22
25
  const has = player?.kind === 'mediaPlayer' && player.capabilities.queue;
23
26
  useEffect(() => {
24
27
  if (!ref || !has) {
@@ -32,9 +35,9 @@ export function useMediaQueue(ref) {
32
35
  current = false;
33
36
  clearInterval(timer);
34
37
  };
35
- // `track`, `shuffle`, `tick` and `reconnects` are not read inside: a change to any of them is the reason to read again.
38
+ // `track`, `shuffle`, `tick`, `reconnects` and `asked` are not read inside: a change to any of them is the reason to read again.
36
39
  // oxlint-disable-next-line react/exhaustive-effect-dependencies
37
- }, [client, ref, has, track, shuffle, tick, reconnects]);
40
+ }, [client, ref, has, track, shuffle, tick, reconnects, asked]);
38
41
  const answered = state !== undefined && state.ref === ref;
39
42
  return {
40
43
  queue: answered ? state.queue : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashsome/ui",
3
- "version": "0.7.0",
3
+ "version": "0.9.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.7.0",
19
+ "@hashsome/core": "0.9.0",
20
20
  "e-prim": "2.0.1",
21
21
  "motion": "13.4.5",
22
22
  "react": "19.3.0",