@kolkrabbi/kol-component 0.236.0 → 0.238.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/package.json +4 -3
- package/src/atoms/Button.jsx +10 -2
- package/src/atoms/Input.jsx +5 -2
- package/src/atoms/Kbd.jsx +6 -3
- package/src/index.js +15 -11
- package/src/molecules/ColorInputRow.jsx +1 -1
- package/src/molecules/ColorRamp.jsx +1 -1
- package/src/molecules/CopyButton.jsx +1 -1
- package/src/molecules/Dropdown.jsx +48 -7
- package/src/molecules/KindPreview.jsx +6 -6
- package/src/{atoms/RotaryDial.jsx → molecules/Knob.jsx} +20 -2
- package/src/molecules/KnobPanel.jsx +116 -0
- package/src/molecules/LabeledControl.jsx +22 -0
- package/src/molecules/MediaPlayer.jsx +107 -0
- package/src/molecules/MenuItem.jsx +5 -2
- package/src/molecules/Modal.jsx +1 -1
- package/src/molecules/MultiSelect.jsx +70 -0
- package/src/molecules/OptionRow.jsx +1 -1
- package/src/molecules/PlayTile.jsx +70 -0
- package/src/molecules/PlaybackBar.jsx +7 -16
- package/src/molecules/RowMenuButton.jsx +1 -1
- package/src/molecules/ShapeDropdown.jsx +3 -3
- package/src/molecules/Slider.jsx +93 -56
- package/src/molecules/SliderPanel.jsx +88 -0
- package/src/molecules/SplitToolButton.jsx +1 -1
- package/src/molecules/SwatchControls.jsx +1 -1
- package/src/molecules/TiltBento.jsx +5 -5
- package/src/organisms/ColumnBrowser.jsx +1 -1
- package/src/organisms/ContentFilters.jsx +1 -1
- package/src/organisms/CurveEditor.jsx +2 -2
- package/src/organisms/FeaturedCarousel.jsx +4 -4
- package/src/organisms/KeyframeEditor.jsx +3 -3
- package/src/organisms/LayerStack.jsx +2 -2
- package/src/organisms/MediaLibrary.jsx +1 -1
- package/src/organisms/MediaLibraryPages.jsx +20 -20
- package/src/organisms/RecordManager.jsx +1 -1
- package/src/organisms/SectionHero.jsx +3 -3
- package/src/organisms/SectionNewsletter.jsx +2 -1
- package/src/organisms/ShellSearchOverlay.jsx +13 -9
- package/src/organisms/SpectrumGrid.jsx +1 -1
- package/src/organisms/ToolPalette.jsx +1 -1
- package/src/{atoms/HlsVideo.jsx → utilities/BackgroundVideo.jsx} +6 -2
- package/src/utilities/ErrorBoundary.jsx +1 -1
- package/src/utilities/Popover.jsx +3 -1
- package/src/utilities/QuadrantSync.jsx +4 -1
- package/src/utilities/armLongPress.js +18 -0
- package/src/utilities/mediaSearch.js +2 -2
- package/src/utilities/tone.js +6 -0
- package/src/atoms/AudioPlayer.jsx +0 -51
- package/src/molecules/AudioPreview.jsx +0 -140
- package/src/molecules/AudioSheet.jsx +0 -48
- package/src/molecules/VideoSheet.jsx +0 -25
- /package/src/{atoms → molecules}/ActionButton.jsx +0 -0
- /package/src/{atoms → molecules}/ColorSwatch.jsx +0 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import usePlayback from '../hooks/usePlayback.js'
|
|
2
|
+
import PlaybackBar, { clock } from './PlaybackBar.jsx'
|
|
3
|
+
import QuickLookFrame from './QuickLookFrame.jsx'
|
|
4
|
+
import FileIcon from '../atoms/FileIcon.jsx'
|
|
5
|
+
import { useCover } from './PlayTile.jsx'
|
|
6
|
+
|
|
7
|
+
/* taxonomy-ok: molecule — nests PlaybackBar + QuickLookFrame (relative) + FileIcon (atom). */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* MediaPlayer — Play a video or an audio file. the media, `PlaybackBar` and the `usePlayback` hook
|
|
11
|
+
* as ONE player (user ruling 2026-10-02: *"I was referring to the two players in the quicklook in
|
|
12
|
+
* media"*). It was four components — `VideoSheet` and `AudioSheet` (the Quick Look pair),
|
|
13
|
+
* `AudioPreview` (its own transport) and `AudioPlayer` (the browser's strip) — and each is an
|
|
14
|
+
* alias of this one now, on the retirement ledger.
|
|
15
|
+
*
|
|
16
|
+
* `variant` is what plays. `frame` is where: given, the player draws inside `QuickLookFrame` (the
|
|
17
|
+
* Quick Look window — the bar docked in its footer); absent, it plays inline (*"why would media
|
|
18
|
+
* player not just prop the window, its not called WINDOWmediaplayer"*).
|
|
19
|
+
*
|
|
20
|
+
* video the picture at its own ratio; click toggles play. Inline, the bar floats over
|
|
21
|
+
* its bottom edge (inset 16, radius 12)
|
|
22
|
+
* audio inline, the bar alone. In the window, Finder's layout: the artwork square (the
|
|
23
|
+
* file's embedded ID3 cover; none → `FileIcon`) beside `Time: mm:ss`
|
|
24
|
+
*
|
|
25
|
+
* No native controls, no autoplay (Quick Look opens paused), `playsInline`.
|
|
26
|
+
*
|
|
27
|
+
* @param {'video'|'audio'} variant what plays (default `video`)
|
|
28
|
+
* @param {string} src the media URL
|
|
29
|
+
* @param {string} poster video — the poster URL
|
|
30
|
+
* @param {string} ext audio — the extension, for the no-cover icon
|
|
31
|
+
* @param {Function} onMeta ({ w, h, len }) => void once metadata lands (audio reports `len` only)
|
|
32
|
+
* @param {Object} frame the window's header — `QuickLookFrame`'s title · meta · actions · onClose · nav · size
|
|
33
|
+
* @param {'none'|'metadata'|'auto'} preload the native preload hint (default `metadata`)
|
|
34
|
+
* @param {string} className inline only — the player's box (its width is the caller's)
|
|
35
|
+
*/
|
|
36
|
+
export default function MediaPlayer({ variant = 'video', src, poster, ext, onMeta, frame, preload = 'metadata', className = '' }) {
|
|
37
|
+
const audio = variant === 'audio'
|
|
38
|
+
const { ref, handlers, bar } = usePlayback((el) => onMeta?.(audio ? { len: el.duration } : { w: el.videoWidth, h: el.videoHeight, len: el.duration }))
|
|
39
|
+
const cover = useCover(audio && frame ? src : null)
|
|
40
|
+
const media = audio
|
|
41
|
+
? <audio ref={ref} src={src} preload={preload} {...handlers} />
|
|
42
|
+
: <video ref={ref} src={src} poster={poster} playsInline preload={preload} className={frame ? 'kol-quicklook-media' : 'block w-full'} onClick={bar.onToggle} {...handlers} />
|
|
43
|
+
|
|
44
|
+
if (frame) {
|
|
45
|
+
return (
|
|
46
|
+
<QuickLookFrame {...frame} footer={<PlaybackBar {...bar} docked />}>
|
|
47
|
+
{media}
|
|
48
|
+
{audio && (
|
|
49
|
+
<div className="kol-quicklook-audio">
|
|
50
|
+
<div className="kol-quicklook-art">
|
|
51
|
+
{cover
|
|
52
|
+
? <img src={cover} alt="" className="w-full h-full object-cover" />
|
|
53
|
+
: <FileIcon ext={ext} glyph="music-note" className="w-[52%]" />}
|
|
54
|
+
</div>
|
|
55
|
+
<span className="kol-mono-16 text-meta">Time: <b className="text-strong">{clock(bar.duration)}</b></span>
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
</QuickLookFrame>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className={`relative ${className}`.trim()}>
|
|
64
|
+
{media}
|
|
65
|
+
<PlaybackBar {...bar} docked={audio} />
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @deprecated 2026-10-02 — use `<MediaPlayer variant="video" frame={…}>`. Drops when nobody imports it (04-retirements.md).
|
|
72
|
+
* VideoSheet — the video in the Quick Look window. Same props.
|
|
73
|
+
*/
|
|
74
|
+
export function VideoSheet({ frame, ...props }) {
|
|
75
|
+
return <MediaPlayer variant="video" frame={frame ?? {}} {...props} />
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @deprecated 2026-10-02 — use `<MediaPlayer variant="audio" frame={…}>`; `onDuration(seconds)` is `onMeta({ len })`. Drops when nobody imports it (04-retirements.md).
|
|
80
|
+
* AudioSheet — audio in the Quick Look window.
|
|
81
|
+
*/
|
|
82
|
+
export function AudioSheet({ frame, onDuration, ...props }) {
|
|
83
|
+
return <MediaPlayer variant="audio" frame={frame ?? {}} onMeta={onDuration && ((m) => onDuration(m.len))} {...props} />
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @deprecated 2026-10-02 — use `<MediaPlayer variant="audio">`; `onDuration(seconds)` is `onMeta({ len })`. Drops when nobody imports it (04-retirements.md).
|
|
88
|
+
* AudioPreview — the inline audio player. Its own transport is the `PlaybackBar` now.
|
|
89
|
+
*/
|
|
90
|
+
export function AudioPreview({ onDuration, ...props }) {
|
|
91
|
+
return <MediaPlayer variant="audio" onMeta={onDuration && ((m) => onDuration(m.len))} {...props} />
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @deprecated 2026-10-02 — use `<MediaPlayer variant="audio">`. Drops when nobody imports it (04-retirements.md).
|
|
96
|
+
* AudioPlayer — the native audio strip with a label. The strip is the
|
|
97
|
+
* `PlaybackBar` now; the label is the caller's to draw.
|
|
98
|
+
*/
|
|
99
|
+
export function AudioPlayer({ src, label, preload, className = '' }) {
|
|
100
|
+
if (!src) return null
|
|
101
|
+
return (
|
|
102
|
+
<figure className={`kol-audio-player flex flex-col gap-3 ${className}`.trim()}>
|
|
103
|
+
{label && <figcaption className="kol-mono-12 text-fg-48">{label}</figcaption>}
|
|
104
|
+
<MediaPlayer variant="audio" src={src} preload={preload} />
|
|
105
|
+
</figure>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -122,7 +122,10 @@ export function MenuItem({
|
|
|
122
122
|
* stack reserves the widest label in its own face, and a row set in a different
|
|
123
123
|
* one measured 3px wider and clipped the selected row beside its check. */
|
|
124
124
|
const ROW_BY_SIZE = { xs: 'kol-mono-8 px-2 h-5', sm: 'kol-helper-12 px-3 h-8', md: 'kol-helper-12 px-3 h-8', lg: 'kol-helper-12 px-3 h-8' }
|
|
125
|
-
|
|
125
|
+
/* `rowClass` — the row's type, side padding and height, whole, in place of the size's own
|
|
126
|
+
* (Dropdown passes it, 2026-10-02: its rows wear the trigger's face and sit on the trigger's
|
|
127
|
+
* text edge; a menu's rows keep `ROW_BY_SIZE`). */
|
|
128
|
+
export function MenuDropdownItem({ onClick, onPointerEnter, onPointerLeave, disabled, prefix, iconLeft, shortcut, hover = true, height, size = 'sm', rowClass, children }) {
|
|
126
129
|
return (
|
|
127
130
|
<button
|
|
128
131
|
type="button"
|
|
@@ -133,7 +136,7 @@ export function MenuDropdownItem({ onClick, onPointerEnter, onPointerLeave, disa
|
|
|
133
136
|
disabled={disabled}
|
|
134
137
|
role="menuitem"
|
|
135
138
|
style={height != null ? { height } : undefined}
|
|
136
|
-
className={`kol-menu-btn w-full ${ROW_BY_SIZE[size] ?? ROW_BY_SIZE.sm} shrink-0 inline-flex items-center gap-2 text-body ${hover ? 'hover:text-emphasis' : ''} disabled:opacity-40 disabled:cursor-not-allowed text-left`}
|
|
139
|
+
className={`kol-menu-btn w-full ${rowClass ?? ROW_BY_SIZE[size] ?? ROW_BY_SIZE.sm} shrink-0 inline-flex items-center gap-2 text-body ${hover ? 'hover:text-emphasis' : ''} disabled:opacity-40 disabled:cursor-not-allowed text-left`}
|
|
137
140
|
>
|
|
138
141
|
{prefix && <span className="shrink-0 inline-flex items-center">{prefix}</span>}
|
|
139
142
|
{iconLeft && <span className="shrink-0 w-4 inline-flex items-center justify-center">{iconLeft}</span>}
|
package/src/molecules/Modal.jsx
CHANGED
|
@@ -120,7 +120,7 @@ function ModalView({ state, closeWith }) {
|
|
|
120
120
|
)}
|
|
121
121
|
<div className="flex gap-2 justify-end">
|
|
122
122
|
{state.kind !== 'alert' && <Button size="sm" onClick={cancel}>{state.cancelLabel ?? 'Cancel'}</Button>}
|
|
123
|
-
<Button
|
|
123
|
+
<Button tone="primary" size="sm" onClick={submit}>{state.okLabel ?? 'OK'}</Button>
|
|
124
124
|
</div>
|
|
125
125
|
</div>
|
|
126
126
|
</div>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
+
import { MenuDropdownItem } from './MenuItem.jsx'
|
|
4
|
+
import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
|
|
5
|
+
import { indicatorSize } from '../hooks/glyphLadders.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* MultiSelect — Several settings in one dropdown. closed it is the Dropdown's trigger, reading the
|
|
9
|
+
* current value of every setting; open it is a popover with one column per setting, each under
|
|
10
|
+
* its name, and it stays open while you pick across columns.
|
|
11
|
+
*
|
|
12
|
+
* WHY IT EXISTS (user, 2026-10-01, on the preview card's knob bar — variant, tone and size as
|
|
13
|
+
* three controls in a row: *"or make a new component with multi select dropdown toggle, like an
|
|
14
|
+
* 'overlay multi settings' component"*, then on the first mock: *"it should popover … I want this
|
|
15
|
+
* component available for further development"*). First cut — the shape is open.
|
|
16
|
+
*
|
|
17
|
+
* Each setting is pick-one; the component holds several of them. For many-of-N in one list use
|
|
18
|
+
* `SettingsMulti`; for one setting use `Dropdown`.
|
|
19
|
+
*
|
|
20
|
+
* @param {Array} groups `[{ id, label, options: [{ value, label }] }]` — one column each
|
|
21
|
+
* @param {Object} value `{ [group.id]: value }` (controlled)
|
|
22
|
+
* @param {Function} onChange `(groupId, value) => void`
|
|
23
|
+
* @param {ReactNode} [label] the trigger's text; default is every current value, joined
|
|
24
|
+
* @param {'xs'|'sm'|'md'|'lg'} [size='sm']
|
|
25
|
+
* @param {string} [className]
|
|
26
|
+
*/
|
|
27
|
+
const SIZE_TYPE = { xs: 'kol-mono-8', sm: 'kol-mono-12', md: 'kol-mono-14', lg: 'kol-mono-16' }
|
|
28
|
+
|
|
29
|
+
export default function MultiSelect({ groups = [], value = {}, onChange, label, size = 'sm', defaultOpen = false, className = '' }) {
|
|
30
|
+
const [open, setOpen] = useState(defaultOpen)
|
|
31
|
+
const popover = usePopover({ open, onOpenChange: setOpen, placement: 'bottom-start', offset: 4, role: 'dialog' })
|
|
32
|
+
const labelOf = (g) => g.options.find((o) => o.value === value[g.id])?.label ?? value[g.id]
|
|
33
|
+
return (
|
|
34
|
+
<div className={`relative inline-block align-middle ${className}`.trim()}>
|
|
35
|
+
<button
|
|
36
|
+
ref={popover.refs.setReference}
|
|
37
|
+
{...popover.getReferenceProps()}
|
|
38
|
+
type="button"
|
|
39
|
+
className={`kol-btn kol-btn-${size} ${SIZE_TYPE[size] ?? SIZE_TYPE.sm} kol-dd-trigger`}
|
|
40
|
+
aria-haspopup="dialog"
|
|
41
|
+
aria-expanded={open}
|
|
42
|
+
data-state={open ? 'open' : 'closed'}
|
|
43
|
+
>
|
|
44
|
+
<span className="kol-dd-label"><span>{label ?? groups.map(labelOf).filter(Boolean).join(' · ')}</span></span>
|
|
45
|
+
<Icon name="chevron-down" size={indicatorSize(size)} className="kol-dd-caret" />
|
|
46
|
+
</button>
|
|
47
|
+
<PopoverPanel popover={popover} className="w-max p-1">
|
|
48
|
+
<div className="flex">
|
|
49
|
+
{groups.map((g) => (
|
|
50
|
+
<div key={g.id} role="listbox" aria-label={g.label} className="flex min-w-32 flex-col">
|
|
51
|
+
{/* the columns touch, so the headings' rules read as one line */}
|
|
52
|
+
<div className="kol-helper-12 mb-1 flex h-8 shrink-0 items-center border-b border-oq-08 px-3 text-meta">{g.label}</div>
|
|
53
|
+
{g.options.map((o) => (
|
|
54
|
+
<MenuDropdownItem
|
|
55
|
+
key={o.value}
|
|
56
|
+
hover={false}
|
|
57
|
+
size={size}
|
|
58
|
+
onClick={() => onChange?.(g.id, o.value)}
|
|
59
|
+
shortcut={o.value === value[g.id] ? <Icon name="check" size={11} /> : null}
|
|
60
|
+
>
|
|
61
|
+
{o.label ?? o.value}
|
|
62
|
+
</MenuDropdownItem>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
</PopoverPanel>
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OptionRow — One row in a list you move through. one row in a list you move through: the
|
|
2
|
+
* OptionRow — One row in a list you move through. one row in a list you move through: the search modal's results, a browser's rows, a
|
|
3
3
|
* dropdown's options. Lifted out of ShellSearchOverlay (2026-09-30), where it was hand-rolled next to
|
|
4
4
|
* DropdownTagFilter's, FieldRow's and ColumnBrowser's own — four rows for one job.
|
|
5
5
|
*
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import IconFrame from '../atoms/IconFrame.jsx';
|
|
3
|
+
import { readCover } from '../utilities/id3.js';
|
|
4
|
+
|
|
5
|
+
/* taxonomy-ok: molecule — nests IconFrame (atom). */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* AudioTile · VideoTile — kol-r2b2's tiles, promoted verbatim (ColumnBrowserMediaFacts
|
|
9
|
+
* 2026-08-27; re-ruled the same day, PlayDiscAndVideoBar). Finder model (user ruling
|
|
10
|
+
* 2026-08-27): the COLUMN gets a square tile over full-bleed artwork with ONE control —
|
|
11
|
+
* the Finder play/pause disc (`.kol-play-disc`: round, hidden at rest, shown on hover of
|
|
12
|
+
* `.kol-media-tile`) — `AudioTile` (artwork = the file's embedded ID3 cover, `readCover`) /
|
|
13
|
+
* `VideoTile` (artwork = the poster; the media element is hidden — the tile never shows a
|
|
14
|
+
* decoded video frame). Timeline + volume belong to the player, `MediaPlayer`.
|
|
15
|
+
*
|
|
16
|
+
* They lived in `AudioPreview.jsx` beside a player of the same name until 2026-10-02, when
|
|
17
|
+
* that player became `MediaPlayer`; this file is what was left.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} src the media URL
|
|
20
|
+
* @param {string} poster VideoTile — the poster URL
|
|
21
|
+
* @param {ReactNode} fallback AudioTile — shown when the file carries no cover art (2026-09-23:
|
|
22
|
+
* it was an empty square, so a WAV read as a missing file)
|
|
23
|
+
*/
|
|
24
|
+
export const formatLength = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
|
|
25
|
+
|
|
26
|
+
/* the file's embedded cover, or null — shared with MediaPlayer's audio window */
|
|
27
|
+
export function useCover(src) {
|
|
28
|
+
const [cover, setCover] = useState(null);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!src) return undefined;
|
|
31
|
+
let live = true;
|
|
32
|
+
readCover(src).then((url) => { if (live) setCover(url); }).catch(() => {});
|
|
33
|
+
return () => { live = false; };
|
|
34
|
+
}, [src]);
|
|
35
|
+
return cover;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* One disc for both tiles — the ruled Finder play/pause. The theme's
|
|
39
|
+
* `.kol-play-disc` paints it after the IconFrame variants, so nothing is restated here. */
|
|
40
|
+
function PlayDisc({ playing, onClick }) {
|
|
41
|
+
return (
|
|
42
|
+
<IconFrame name={playing ? 'pause' : 'play'} variant="secondary" size="lg" radius="full" onClick={onClick} aria-label={playing ? 'Pause' : 'Play'} className="relative kol-play-disc" />
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function AudioTile({ src, fallback = null }) {
|
|
47
|
+
const ref = useRef(null);
|
|
48
|
+
const [playing, setPlaying] = useState(false);
|
|
49
|
+
const cover = useCover(src);
|
|
50
|
+
return (
|
|
51
|
+
<div className="kol-media-tile relative w-full aspect-square rounded overflow-hidden flex items-center justify-center">
|
|
52
|
+
<audio ref={ref} src={src} preload="metadata" onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} />
|
|
53
|
+
{cover ? <img src={cover} alt="" className="absolute inset-0 w-full h-full object-cover" /> : fallback && <div className="absolute inset-0 flex items-center justify-center">{fallback}</div>}
|
|
54
|
+
<PlayDisc playing={playing} onClick={() => (playing ? ref.current.pause() : ref.current.play())} />
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function VideoTile({ src, poster }) {
|
|
60
|
+
const ref = useRef(null);
|
|
61
|
+
const [playing, setPlaying] = useState(false);
|
|
62
|
+
return (
|
|
63
|
+
<div className="kol-media-tile relative w-full aspect-square rounded overflow-hidden flex items-center justify-center">
|
|
64
|
+
{/* Same as audio: the media element is hidden, the artwork (the poster) is the tile. */}
|
|
65
|
+
<video ref={ref} src={src} playsInline preload="metadata" className={poster ? 'hidden' : 'absolute inset-0 w-full h-full object-cover'} onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} />
|
|
66
|
+
{poster && <img src={poster} alt="" className="absolute inset-0 w-full h-full object-cover" />}
|
|
67
|
+
<PlayDisc playing={playing} onClick={() => (playing ? ref.current.pause() : ref.current.play())} />
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/* eslint-disable react-hooks/refs -- floating-ui's `refs.setReference` is a callback ref, as in the DS Tooltip */
|
|
2
2
|
import { useState } from 'react'
|
|
3
3
|
import IconFrame from '../atoms/IconFrame.jsx'
|
|
4
|
+
import Slider from './Slider.jsx'
|
|
4
5
|
import { usePopover, PopoverPanel } from '../utilities/Popover.jsx'
|
|
5
6
|
|
|
6
|
-
/* taxonomy-ok: molecule — nests IconFrame (atom) + PopoverPanel (relative). */
|
|
7
|
+
/* taxonomy-ok: molecule — nests IconFrame (atom) + Slider / PopoverPanel (relative). */
|
|
7
8
|
|
|
8
9
|
/* mm:ss — the reference bar shows two-digit minutes (00:12 · 00:25) */
|
|
9
10
|
export const clock = (s) => `${String(Math.floor((s || 0) / 60)).padStart(2, '0')}:${String(Math.floor((s || 0) % 60)).padStart(2, '0')}`
|
|
@@ -18,9 +19,9 @@ const RATES = [1, 1.5, 2]
|
|
|
18
19
|
* gap-7` — white glyphs whatever the theme (`.kol-playback-bar`, kol-theme ≥0.75.0:
|
|
19
20
|
* opacity .8, 1 on hover / focus-visible), the transport cluster (`skip-back-15` ·
|
|
20
21
|
* play / pause · `skip-forward-15`, ghost IconFrames, gap-5), elapsed as `mm:ss`
|
|
21
|
-
* (`kol-mono-16 tabular-nums`),
|
|
22
|
-
* a 4 × 28
|
|
23
|
-
* remaining), volume behind `speaker` (
|
|
22
|
+
* (`kol-mono-16 tabular-nums`), the scrubber (`Slider variant="scrub"`: a 2px
|
|
23
|
+
* track, a 4 × 28 pill knob — white over media), the TOTAL length (not the
|
|
24
|
+
* remaining), volume behind `speaker` (`Slider direction="vertical"` in a
|
|
24
25
|
* PopoverPanel), and `>>` (`chevrons-right`) cycling the speed 1 → 1.5 → 2.
|
|
25
26
|
*
|
|
26
27
|
* PRESENTATIONAL — the sheet owns the media element (`usePlayback`); this bar
|
|
@@ -59,23 +60,13 @@ export default function PlaybackBar({ playing, time, duration, rate = 1, onToggl
|
|
|
59
60
|
<IconFrame name="skip-forward-15" variant="ghost" size="md" iconSize={22} onClick={() => onSeek(time + 15)} aria-label="Forward 15 seconds" />
|
|
60
61
|
</div>
|
|
61
62
|
<span className="kol-mono-16 tabular-nums opacity-80">{clock(time)}</span>
|
|
62
|
-
<
|
|
63
|
+
<Slider variant="scrub" readout="none" className="flex-1 min-w-0" min={0} max={duration || 0} step={0.1} value={time} onChange={onSeek} aria-label="Scrub" />
|
|
63
64
|
<span className="kol-mono-16 tabular-nums opacity-80">{clock(duration)}</span>
|
|
64
65
|
<span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
|
|
65
66
|
<IconFrame name="speaker" variant={open ? 'secondary' : 'ghost'} size="md" onClick={() => {}} aria-label="Volume" />
|
|
66
67
|
</span>
|
|
67
68
|
<PopoverPanel popover={pop} className="p-2">
|
|
68
|
-
<
|
|
69
|
-
<input
|
|
70
|
-
type="range"
|
|
71
|
-
min={0}
|
|
72
|
-
max={100}
|
|
73
|
-
value={volume}
|
|
74
|
-
onChange={(e) => { const v = Number(e.target.value); onVolume?.(v / 100); setVolume(v) }}
|
|
75
|
-
className="slider-black cursor-pointer w-24 -rotate-90"
|
|
76
|
-
aria-label="Volume"
|
|
77
|
-
/>
|
|
78
|
-
</div>
|
|
69
|
+
<Slider direction="vertical" readout="none" min={0} max={100} value={volume} onChange={(v) => { onVolume?.(v / 100); setVolume(v) }} aria-label="Volume" />
|
|
79
70
|
</PopoverPanel>
|
|
80
71
|
{onRate && (
|
|
81
72
|
<span className="flex items-center gap-1">
|
|
@@ -16,5 +16,5 @@ import useCoarsePointer from '../hooks/useCoarsePointer.js'
|
|
|
16
16
|
export default function RowMenuButton({ onOpen, variant = 'ghost', className = '' }) {
|
|
17
17
|
const coarse = useCoarsePointer()
|
|
18
18
|
if (!coarse || !onOpen) return null
|
|
19
|
-
return <Button
|
|
19
|
+
return <Button tone={variant} size="md" iconOnly="more" aria-label="More actions" className={`shrink-0 ${className}`.trim()} onClick={onOpen} />
|
|
20
20
|
}
|
|
@@ -46,7 +46,7 @@ const ShapeDropdown = ({ options = [], value, onChange, onAction, className = ''
|
|
|
46
46
|
{current?.icon ? (
|
|
47
47
|
<Tooltip label={current.label}>
|
|
48
48
|
<Button
|
|
49
|
-
|
|
49
|
+
tone="ghost"
|
|
50
50
|
size="sm"
|
|
51
51
|
quiet
|
|
52
52
|
iconOnly={current.icon}
|
|
@@ -55,7 +55,7 @@ const ShapeDropdown = ({ options = [], value, onChange, onAction, className = ''
|
|
|
55
55
|
/>
|
|
56
56
|
</Tooltip>
|
|
57
57
|
) : (
|
|
58
|
-
<Button
|
|
58
|
+
<Button tone="ghost" size="sm" quiet onClick={() => onAction?.(current?.id)}>
|
|
59
59
|
{current?.label}
|
|
60
60
|
</Button>
|
|
61
61
|
)}
|
|
@@ -67,7 +67,7 @@ const ShapeDropdown = ({ options = [], value, onChange, onAction, className = ''
|
|
|
67
67
|
{...popover.getReferenceProps()}
|
|
68
68
|
className="inline-flex"
|
|
69
69
|
>
|
|
70
|
-
<Button
|
|
70
|
+
<Button tone="ghost" size="sm" quiet iconOnly="chevron-down" iconSize={10} aria-label="Variants" />
|
|
71
71
|
</span>
|
|
72
72
|
{/* w-max: a float sizes to its CONTENT (floating-ui contract) — without
|
|
73
73
|
* it the absolutely-positioned panel can stretch against its containing
|
package/src/molecules/Slider.jsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import Input from '../atoms/Input.jsx'
|
|
3
|
+
import SliderPanel from './SliderPanel.jsx'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Slider — range slider with label and a value readout. The LINEAR VARIANT of
|
|
6
|
-
*
|
|
7
|
+
* Knob (molecules/Knob.jsx): both implement the shared value-control
|
|
7
8
|
* contract — `value` / `min` / `max` / `step` / `onChange(next: number)` /
|
|
8
9
|
* `label` / `size` / `disabled` / `formatValue` / `defaultValue`.
|
|
9
10
|
* Controlled; onChange always fires with the plain number.
|
|
@@ -33,16 +34,20 @@ import Input from '../atoms/Input.jsx'
|
|
|
33
34
|
* @param {number} props.value - Controlled value within min–max
|
|
34
35
|
* @param {Function} props.onChange - (next: number) => void change handler
|
|
35
36
|
* @param {number} props.step - Slider step increment (default: 1)
|
|
36
|
-
* @param {number} props.size - Track length in px (mirrors
|
|
37
|
+
* @param {number} props.size - Track length in px (mirrors Knob's dial px size); unset = fluid flex-1 track
|
|
37
38
|
* @param {boolean} props.disabled - Disables track + readout and dims the control (default: false)
|
|
38
|
-
* @param {Function} props.formatValue - Optional formatter for displayed value;
|
|
39
|
+
* @param {Function} props.formatValue - Optional formatter for displayed value; Knob's default readout is `${value}%`
|
|
39
40
|
* @param {string} props.className - Additional wrapper classes
|
|
40
41
|
* @param {number} props.displayWidth - Width of the value readout, in characters (default: 6)
|
|
41
42
|
* @param {string} props.fontSize - Font size for label/value (e.g., '11px')
|
|
42
43
|
* @param {Object} props.style - forwarded to the wrapper; the seam for `--kol-slider-track` / `--kol-slider-playhead`
|
|
43
|
-
* @param {number} props.defaultValue - alt-click the control resets to this (falls back to `min`). Same contract on
|
|
44
|
-
* @param {'input'|'value'|'none'} props.readout - `input` (default) an editable Input · `value` a plain right-aligned span,
|
|
45
|
-
* @param {'minimal'|'dual'} props.variant - `dual` = two thumbs on one rail
|
|
44
|
+
* @param {number} props.defaultValue - alt-click the control resets to this (falls back to `min`). Same contract on Knob
|
|
45
|
+
* @param {'input'|'value'|'none'} props.readout - `input` (default) an editable Input · `value` a plain right-aligned span, Knob's own display-only readout · `none`
|
|
46
|
+
* @param {'minimal'|'dual'|'scrub'|'panel'} props.variant - `dual` = two thumbs on one rail · `scrub` = a media scrubber: the thin track with the 4 × 28 pill knob (`.kol-playback-scrub`; `PlaybackBar`'s, 2026-10-02) · `panel` = the rack slider (was kol-hardware `Fader`, merged 2026-10-01): a drawn 2px track for a hardware panel; takes `value · onChange · min · max · step · label` plus `onHold`
|
|
47
|
+
* @param {'horizontal'|'vertical'} props.direction - `vertical` stands the track upright at `height`, label above and readout below (2026-10-02 — the volume ranges were hand-drawn for want of it). On `panel`, horizontal takes the row and shows a readout
|
|
48
|
+
* @param {number} props.height - vertical only — the track's length in px (default 96; panel 60)
|
|
49
|
+
* @param {string} props['aria-label'] - names the range when there is no visible `label`
|
|
50
|
+
* @param {Function} props.onHold - panel only — touch hold, 500ms: `({ label, value, min, max, step }) => void`; the holder opens its sheet
|
|
46
51
|
* @param {number} props.value2 - dual only — the out value
|
|
47
52
|
* @param {Function} props.onChange2 - dual only — (next: number) => void for the out thumb
|
|
48
53
|
* @param {string} props.label1 - dual only — replaces the formatted in value above the rail
|
|
@@ -73,6 +78,10 @@ const Slider = ({
|
|
|
73
78
|
playhead = null,
|
|
74
79
|
onPlayheadChange,
|
|
75
80
|
style,
|
|
81
|
+
direction,
|
|
82
|
+
height,
|
|
83
|
+
onHold,
|
|
84
|
+
'aria-label': ariaLabel,
|
|
76
85
|
}) => {
|
|
77
86
|
/* Label ↔ input pairing — the <label> is a sibling of the range input, so
|
|
78
87
|
* without an htmlFor/id pair the visible label confers no accessible name. */
|
|
@@ -160,7 +169,7 @@ const Slider = ({
|
|
|
160
169
|
if (e.key === 'Escape') { setDraft(displayValue); setEditing(false); e.currentTarget.blur() }
|
|
161
170
|
}
|
|
162
171
|
|
|
163
|
-
/* alt-click anywhere on the control resets —
|
|
172
|
+
/* alt-click anywhere on the control resets — Knob carries the same
|
|
164
173
|
* gesture, so a reset that worked on the knob and not the fader would be the
|
|
165
174
|
* shared value-control contract splitting again. */
|
|
166
175
|
const onAltReset = (e) => {
|
|
@@ -169,6 +178,10 @@ const Slider = ({
|
|
|
169
178
|
onChange(defaultValue ?? min)
|
|
170
179
|
}
|
|
171
180
|
|
|
181
|
+
if (variant === 'panel') {
|
|
182
|
+
return <SliderPanel value={value} onChange={onChange} min={min} max={max} step={step} label={label} direction={direction} height={height} onHold={onHold} />
|
|
183
|
+
}
|
|
184
|
+
|
|
172
185
|
if (variant === 'dual') {
|
|
173
186
|
const v1 = value ?? min
|
|
174
187
|
const v2 = value2 ?? max
|
|
@@ -236,56 +249,80 @@ const Slider = ({
|
|
|
236
249
|
)
|
|
237
250
|
}
|
|
238
251
|
|
|
252
|
+
const vertical = direction === 'vertical'
|
|
253
|
+
const scrub = variant === 'scrub'
|
|
254
|
+
const length = vertical ? height ?? 96 : size
|
|
255
|
+
|
|
256
|
+
const labelNode = label && (
|
|
257
|
+
<label
|
|
258
|
+
htmlFor={sliderId}
|
|
259
|
+
className={`kol-helper-12 whitespace-nowrap shrink-0 w-fit ${disabled ? 'opacity-50' : ''}`}
|
|
260
|
+
style={fontSize ? { fontSize } : undefined}
|
|
261
|
+
>
|
|
262
|
+
{label}
|
|
263
|
+
</label>
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
const range = (
|
|
267
|
+
<input
|
|
268
|
+
id={sliderId}
|
|
269
|
+
type="range"
|
|
270
|
+
min={min}
|
|
271
|
+
max={max}
|
|
272
|
+
step={step}
|
|
273
|
+
value={value}
|
|
274
|
+
onChange={handleChange}
|
|
275
|
+
disabled={disabled}
|
|
276
|
+
aria-label={ariaLabel}
|
|
277
|
+
className={`${scrub ? 'kol-playback-scrub' : 'slider-black cursor-pointer disabled:cursor-default'} disabled:opacity-50 ${vertical ? '-rotate-90 flex-none' : length ? 'flex-none' : 'flex-1 w-full min-w-0'}`}
|
|
278
|
+
/* upright, the drag is vertical — so the page's pan is the horizontal one */
|
|
279
|
+
style={length ? { width: length, ...(vertical && { touchAction: 'pan-x' }) } : undefined}
|
|
280
|
+
/>
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
const readoutNode = readout === 'input' ? (
|
|
284
|
+
<Input
|
|
285
|
+
type="text"
|
|
286
|
+
inputMode="decimal"
|
|
287
|
+
variant="filled"
|
|
288
|
+
size="sm"
|
|
289
|
+
chars={displayWidth}
|
|
290
|
+
value={draft}
|
|
291
|
+
disabled={disabled}
|
|
292
|
+
onFocus={(e) => { setEditing(true); e.target.select() }}
|
|
293
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
294
|
+
onBlur={commit}
|
|
295
|
+
onKeyDown={onKeyDown}
|
|
296
|
+
inputClassName="text-center"
|
|
297
|
+
/>
|
|
298
|
+
) : readout === 'value' ? (
|
|
299
|
+
/* `value` is Knob's readout, not a second design — a mixer running
|
|
300
|
+
* ~23 faders in a 24px row cannot afford an input chip on every one, and
|
|
301
|
+
* that is why the easy call sites could not move. */
|
|
302
|
+
<span
|
|
303
|
+
className={`kol-helper-12 shrink-0 w-fit text-right ${disabled ? 'opacity-50' : ''}`}
|
|
304
|
+
style={fontSize ? { fontSize } : undefined}
|
|
305
|
+
>
|
|
306
|
+
{displayValue}
|
|
307
|
+
</span>
|
|
308
|
+
) : null
|
|
309
|
+
|
|
310
|
+
if (vertical) {
|
|
311
|
+
return (
|
|
312
|
+
<div className={`inline-flex flex-col items-center gap-2 ${className}`} style={style} onClick={onAltReset}>
|
|
313
|
+
{labelNode}
|
|
314
|
+
{/* a native range turned upright: the box is the track's length tall, the input that long */}
|
|
315
|
+
<div className="w-6 flex items-center justify-center" style={{ height: length }}>{range}</div>
|
|
316
|
+
{readoutNode}
|
|
317
|
+
</div>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
239
321
|
return (
|
|
240
|
-
<div className={`control-slider gap-3 shadow-none ${className}`} style={style} onClick={onAltReset}>
|
|
241
|
-
{
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
className={`kol-helper-12 whitespace-nowrap shrink-0 w-fit ${disabled ? 'opacity-50' : ''}`}
|
|
245
|
-
style={fontSize ? { fontSize } : undefined}
|
|
246
|
-
>
|
|
247
|
-
{label}
|
|
248
|
-
</label>
|
|
249
|
-
)}
|
|
250
|
-
<input
|
|
251
|
-
id={sliderId}
|
|
252
|
-
type="range"
|
|
253
|
-
min={min}
|
|
254
|
-
max={max}
|
|
255
|
-
step={step}
|
|
256
|
-
value={value}
|
|
257
|
-
onChange={handleChange}
|
|
258
|
-
disabled={disabled}
|
|
259
|
-
className={`slider-black cursor-pointer disabled:cursor-default disabled:opacity-50 ${size ? 'flex-none' : 'flex-1 w-full'}`}
|
|
260
|
-
style={size ? { width: size } : undefined}
|
|
261
|
-
/>
|
|
262
|
-
{readout === 'input' && (
|
|
263
|
-
<Input
|
|
264
|
-
type="text"
|
|
265
|
-
inputMode="decimal"
|
|
266
|
-
variant="filled"
|
|
267
|
-
size="sm"
|
|
268
|
-
chars={displayWidth}
|
|
269
|
-
value={draft}
|
|
270
|
-
disabled={disabled}
|
|
271
|
-
onFocus={(e) => { setEditing(true); e.target.select() }}
|
|
272
|
-
onChange={(e) => setDraft(e.target.value)}
|
|
273
|
-
onBlur={commit}
|
|
274
|
-
onKeyDown={onKeyDown}
|
|
275
|
-
inputClassName="text-center"
|
|
276
|
-
/>
|
|
277
|
-
)}
|
|
278
|
-
{/* `value` is RotaryDial's readout, not a second design — a mixer running
|
|
279
|
-
* ~23 faders in a 24px row cannot afford an input chip on every one, and
|
|
280
|
-
* that is why the easy call sites could not move. */}
|
|
281
|
-
{readout === 'value' && (
|
|
282
|
-
<span
|
|
283
|
-
className={`kol-helper-12 shrink-0 w-fit text-right ${disabled ? 'opacity-50' : ''}`}
|
|
284
|
-
style={fontSize ? { fontSize } : undefined}
|
|
285
|
-
>
|
|
286
|
-
{displayValue}
|
|
287
|
-
</span>
|
|
288
|
-
)}
|
|
322
|
+
<div className={`control-slider gap-3 shadow-none ${scrub ? 'h-7' : ''} ${className}`} style={style} onClick={onAltReset}>
|
|
323
|
+
{labelNode}
|
|
324
|
+
{range}
|
|
325
|
+
{readoutNode}
|
|
289
326
|
</div>
|
|
290
327
|
)
|
|
291
328
|
}
|