@kolkrabbi/kol-component 0.236.0 → 0.237.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 -4
- package/src/atoms/Button.jsx +10 -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 +5 -2
- 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/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 +8 -8
- 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/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,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
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useRef, useCallback } from 'react'
|
|
2
|
+
import { armLongPress } from '../utilities/armLongPress.js'
|
|
3
|
+
|
|
4
|
+
const NO_HOLD = { move() {}, cancel() {} }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SliderPanel — Slider's `variant="panel"`: the rack slider. A 2px track and an 8px thumb for a
|
|
8
|
+
* 24px hardware panel, drawn rather than a native range, with a vertical direction. It was
|
|
9
|
+
* kol-hardware's `Fader` (kol-monitor's rack, lifted 2026-09-01) and is carried here class for
|
|
10
|
+
* class — merged into Slider 2026-10-01 (user ruling: one slider, not two that look alike).
|
|
11
|
+
* Not exported: reach it through `<Slider variant="panel">`.
|
|
12
|
+
*/
|
|
13
|
+
export default function SliderPanel({ value, onChange, min = 0, max = 100, step = 1, label, direction = 'horizontal', height, onHold }) {
|
|
14
|
+
const trackRef = useRef(null)
|
|
15
|
+
const vertical = direction === 'vertical'
|
|
16
|
+
|
|
17
|
+
const handlePointerDown = useCallback((e) => {
|
|
18
|
+
e.preventDefault()
|
|
19
|
+
const track = trackRef.current
|
|
20
|
+
if (!track) return
|
|
21
|
+
|
|
22
|
+
const update = (e) => {
|
|
23
|
+
const rect = track.getBoundingClientRect()
|
|
24
|
+
const ratio = vertical
|
|
25
|
+
? Math.max(0, Math.min(1, 1 - (e.clientY - rect.top) / rect.height))
|
|
26
|
+
: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
|
27
|
+
const raw = min + ratio * (max - min)
|
|
28
|
+
onChange(Math.round(raw / step) * step)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
update(e)
|
|
32
|
+
const id = e.pointerId
|
|
33
|
+
const onMove = (e) => { if (e.pointerId === id) { hold.move(e); update(e) } } // a second finger is a pan
|
|
34
|
+
const onUp = () => {
|
|
35
|
+
hold.cancel()
|
|
36
|
+
window.removeEventListener('pointermove', onMove)
|
|
37
|
+
window.removeEventListener('pointerup', onUp)
|
|
38
|
+
}
|
|
39
|
+
// Touch: hold still 500ms → the holder's sheet instead of the drag
|
|
40
|
+
const hold = onHold ? armLongPress(e, () => { onUp(); onHold({ label, value, min, max, step }) }) : NO_HOLD
|
|
41
|
+
window.addEventListener('pointermove', onMove)
|
|
42
|
+
window.addEventListener('pointerup', onUp)
|
|
43
|
+
}, [min, max, step, onChange, vertical, onHold, label, value])
|
|
44
|
+
|
|
45
|
+
const norm = max > min ? ((value ?? 0) - min) / (max - min) : 0
|
|
46
|
+
|
|
47
|
+
if (vertical) {
|
|
48
|
+
return (
|
|
49
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
|
50
|
+
{label && (
|
|
51
|
+
<span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1 }}>
|
|
52
|
+
{label}
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
55
|
+
<div
|
|
56
|
+
ref={trackRef}
|
|
57
|
+
onPointerDown={handlePointerDown}
|
|
58
|
+
className="bg-fg-16"
|
|
59
|
+
style={{ width: 2, height: height ?? 60, position: 'relative', cursor: 'ns-resize', touchAction: 'none' }}
|
|
60
|
+
>
|
|
61
|
+
<div className="bg-fg-16" style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: `${norm * 100}%` }} />
|
|
62
|
+
<div className="bg-fg-72" style={{ position: 'absolute', bottom: `calc(${norm * 100}% - 4px)`, left: -3, width: 8, height: 8, borderRadius: '50%' }} />
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
|
|
70
|
+
{label && (
|
|
71
|
+
<span className="kol-helper-8 text-fg-32" style={{ textTransform: 'uppercase', lineHeight: 1, flexShrink: 0, minWidth: 20 }}>
|
|
72
|
+
{label}
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
<div
|
|
76
|
+
ref={trackRef}
|
|
77
|
+
onPointerDown={handlePointerDown}
|
|
78
|
+
className="bg-fg-16"
|
|
79
|
+
style={{ flex: 1, height: 2, position: 'relative', cursor: 'pointer', touchAction: 'none' }}
|
|
80
|
+
>
|
|
81
|
+
<div className="bg-fg-72" style={{ position: 'absolute', left: `calc(${norm * 100}% - 4px)`, top: -3, width: 8, height: 8, borderRadius: '50%' }} />
|
|
82
|
+
</div>
|
|
83
|
+
<span className="kol-helper-8 text-fg-32" style={{ lineHeight: 1, flexShrink: 0, minWidth: 16, textAlign: 'right' }}>
|
|
84
|
+
{Math.round(value ?? 0)}
|
|
85
|
+
</span>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -17,7 +17,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
|
|
|
17
17
|
* corner fold inside its own box, and `blurOnClick` must blur the button
|
|
18
18
|
* itself. The class output — kol-btn kol-btn-ghost kol-btn-icon kol-btn-{size}
|
|
19
19
|
* + kol-btn-quiet / kol-btn-pressed — is exactly what
|
|
20
|
-
* `<Button
|
|
20
|
+
* `<Button tone="ghost" quiet pressed iconOnly>` emits, so the box and the
|
|
21
21
|
* visual contract stay Button's.
|
|
22
22
|
*
|
|
23
23
|
* `size` was a raw px number (28) with a transcribed 14px glyph until 0.182.0:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import useInViewAttention from '../hooks/useInViewAttention.js'
|
|
3
3
|
import { motion } from 'framer-motion'
|
|
4
|
-
import
|
|
4
|
+
import BackgroundVideo from '../utilities/BackgroundVideo.jsx'
|
|
5
5
|
import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
|
|
6
6
|
import Button from '../atoms/Button.jsx'
|
|
7
7
|
import Image from '../atoms/Image.jsx'
|
|
@@ -9,7 +9,7 @@ import useTilt from '../hooks/useTilt.js'
|
|
|
9
9
|
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
10
10
|
|
|
11
11
|
/* taxonomy-ok: nests the same-file Media helper plus DS atoms/molecules
|
|
12
|
-
* (
|
|
12
|
+
* (BackgroundVideo, AssetPlaceholder, Button, Image) it composes — an organism. */
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* True on coarse-pointer (touch/no-hover) devices. Local to TiltBento, mirrors
|
|
@@ -37,7 +37,7 @@ function useCoarsePointer() {
|
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Media — internal, NOT exported. Sniffs `src` by extension and renders the
|
|
40
|
-
* right full-bleed media element: `.m3u8` →
|
|
40
|
+
* right full-bleed media element: `.m3u8` → BackgroundVideo, `.mov|.mp4|.webm` →
|
|
41
41
|
* autoplay <video>, any other src → the Image molecule (its own broken-asset
|
|
42
42
|
* fallback), and no src → AssetPlaceholder. Positioning/fit arrive via
|
|
43
43
|
* `className`; Image gets an inline height so its base `h-auto` can't beat the
|
|
@@ -45,7 +45,7 @@ function useCoarsePointer() {
|
|
|
45
45
|
*/
|
|
46
46
|
function Media({ src, poster, className }) {
|
|
47
47
|
if (!src) return <AssetPlaceholder className={className} />
|
|
48
|
-
if (/\.m3u8$/i.test(src)) return <
|
|
48
|
+
if (/\.m3u8$/i.test(src)) return <BackgroundVideo src={src} poster={poster} className={className} />
|
|
49
49
|
if (/\.(mov|mp4|webm)$/i.test(src)) {
|
|
50
50
|
return (
|
|
51
51
|
<video
|
|
@@ -198,7 +198,7 @@ export default function TiltBento({
|
|
|
198
198
|
<div className={revealClass}>
|
|
199
199
|
<Button
|
|
200
200
|
href={href}
|
|
201
|
-
|
|
201
|
+
tone="primary"
|
|
202
202
|
size="sm"
|
|
203
203
|
{...(isExternal
|
|
204
204
|
? { target: '_blank', rel: 'noreferrer noopener' }
|
|
@@ -4,7 +4,7 @@ import KindPreview, { KIND_GLYPH } from '../molecules/KindPreview.jsx'
|
|
|
4
4
|
import FileIcon from '../atoms/FileIcon.jsx'
|
|
5
5
|
import RowMenuButton from '../molecules/RowMenuButton.jsx'
|
|
6
6
|
import OptionRow from '../molecules/OptionRow.jsx'
|
|
7
|
-
import { formatLength } from '../molecules/
|
|
7
|
+
import { formatLength } from '../molecules/PlayTile.jsx'
|
|
8
8
|
import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
|
|
9
9
|
import useGrabEdge from '../hooks/useGrabEdge.js'
|
|
10
10
|
import useMediaQuery from '../hooks/useMediaQuery.js'
|
|
@@ -117,7 +117,7 @@ const ContentFilters = ({
|
|
|
117
117
|
searchKeys = ['label', 'name', 'title', 'type'],
|
|
118
118
|
/* CONTROLLED SEARCH (2026-09-30) — same seam as `filtersOpen`: a consumer that must know the
|
|
119
119
|
* query (media swaps the tree for a flat list while one is typed) or hand one in (⌘Enter from its
|
|
120
|
-
*
|
|
120
|
+
* search modal) owns it. Absent, the text stays private, exactly as before. */
|
|
121
121
|
searchValue,
|
|
122
122
|
onSearchChange,
|
|
123
123
|
headerActions,
|
|
@@ -79,7 +79,7 @@ function TermsEditor({ def, commit }) {
|
|
|
79
79
|
<div className="flex flex-col gap-2">
|
|
80
80
|
<div className="flex items-center justify-between">
|
|
81
81
|
<span className="kol-helper-10 text-meta">Vectors</span>
|
|
82
|
-
<Button
|
|
82
|
+
<Button tone="primary" size="sm" onClick={addTerm}>Add</Button>
|
|
83
83
|
</div>
|
|
84
84
|
<div className="grid grid-cols-[1fr_1fr_1fr_auto] gap-1.5 items-center min-w-0 [&>*]:min-w-0">
|
|
85
85
|
<span className="kol-helper-10 text-fg-48 text-center">amp</span>
|
|
@@ -103,7 +103,7 @@ function TermRow({ term, onSet, onRemove, canRemove }) {
|
|
|
103
103
|
{cell('amp', 1)}
|
|
104
104
|
{cell('freq', 1)}
|
|
105
105
|
{cell('phase', 0)}
|
|
106
|
-
<Button
|
|
106
|
+
<Button tone="ghost" size="sm" onClick={onRemove} disabled={!canRemove} aria-label="Remove vector">×</Button>
|
|
107
107
|
</>
|
|
108
108
|
)
|
|
109
109
|
}
|