@hashsome/ui 0.7.0 → 0.8.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 +24 -9
- package/dist/entities/media-controls.d.ts +22 -4
- package/dist/entities/media-controls.js +45 -10
- package/dist/entities/media-loading-line.d.ts +9 -0
- package/dist/entities/media-loading-line.js +14 -0
- package/dist/entities/media-pending.d.ts +24 -0
- package/dist/entities/media-pending.js +121 -0
- package/dist/entities/media-player-bar.js +10 -6
- package/dist/entities/media-progress.d.ts +4 -0
- package/dist/entities/media-progress.js +41 -0
- package/dist/entities/now-playing.js +9 -6
- package/dist/layout/drawer-controls.js +46 -3
- package/dist/layout/fade-scroll.d.ts +2 -0
- package/dist/layout/fade-scroll.js +1 -1
- package/dist/layout/icon-button-styles.js +3 -1
- package/dist/layout/spinner.d.ts +13 -0
- package/dist/layout/spinner.js +28 -0
- package/dist/layout/tile.d.ts +1 -1
- package/dist/layout/tile.js +3 -1
- package/dist/use-media-queue.js +5 -2
- 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
|
-
|
|
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,13 @@
|
|
|
1
|
+
/** A turning ring: something is in progress and will not say how long it takes. Kept turning when the
|
|
2
|
+
* app is set to reduced motion (`data-keep-motion`): a spinner that stopped would look stuck, and one
|
|
3
|
+
* small turning icon costs next to nothing. */
|
|
4
|
+
export declare function Spinner({ size, label }: {
|
|
5
|
+
size?: number;
|
|
6
|
+
label?: string;
|
|
7
|
+
}): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
8
|
+
/** A ring that turns around the edge of a round button (give the button `position: relative`): the
|
|
9
|
+
* button is waiting for an answer and will not take another press. Kept turning under reduced motion
|
|
10
|
+
* for the same reason `Spinner` is. */
|
|
11
|
+
export declare function SpinnerRing({ label }: {
|
|
12
|
+
label?: string;
|
|
13
|
+
}): import("@emotion/react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx } from "@emotion/react/jsx-runtime";
|
|
2
|
+
/** @jsxImportSource @emotion/react */
|
|
3
|
+
import { keyframes } from '@emotion/react';
|
|
4
|
+
import { Icon } from '../icon.js';
|
|
5
|
+
const turn = keyframes({ to: { transform: 'rotate(360deg)' } });
|
|
6
|
+
/** A turning ring: something is in progress and will not say how long it takes. Kept turning when the
|
|
7
|
+
* app is set to reduced motion (`data-keep-motion`): a spinner that stopped would look stuck, and one
|
|
8
|
+
* small turning icon costs next to nothing. */
|
|
9
|
+
export function Spinner({ size = 16, label = 'Loading' }) {
|
|
10
|
+
return (_jsx("span", { role: "status", "aria-label": label, "data-keep-motion": true, css: {
|
|
11
|
+
display: 'inline-grid',
|
|
12
|
+
placeItems: 'center',
|
|
13
|
+
flex: 'none',
|
|
14
|
+
animation: `${turn} 0.9s linear infinite`,
|
|
15
|
+
}, children: _jsx(Icon, { name: "lu:loader-circle", size: size }) }));
|
|
16
|
+
}
|
|
17
|
+
/** A ring that turns around the edge of a round button (give the button `position: relative`): the
|
|
18
|
+
* button is waiting for an answer and will not take another press. Kept turning under reduced motion
|
|
19
|
+
* 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" }) }) }));
|
|
28
|
+
}
|
package/dist/layout/tile.d.ts
CHANGED
|
@@ -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`
|
|
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;
|
package/dist/layout/tile.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/use-media-queue.js
CHANGED
|
@@ -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 `
|
|
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.
|
|
3
|
+
"version": "0.8.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.8.0",
|
|
20
20
|
"e-prim": "2.0.1",
|
|
21
21
|
"motion": "13.4.5",
|
|
22
22
|
"react": "19.3.0",
|