Style sliders and add active indicators for interactable componentspull/1/head
@@ -6,11 +6,13 @@ import { | |||
formatSecondsDurationConcise, | |||
formatSecondsDurationPrecise, | |||
} from '@/utils/numeral'; | |||
import theme from '@/styles/theme'; | |||
import {useMediaControls} from '../../hooks/interactive'; | |||
import {useAugmentedFile} from '@/categories/blob/react'; | |||
import clsx from 'clsx'; | |||
import {WaveSurferCanvas} from '@/packages/react-wavesurfer'; | |||
import {Slider} from '@/categories/number/react'; | |||
type AudioFilePreviewDerivedComponent = HTMLAudioElement; | |||
@@ -99,53 +101,59 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
</audio> | |||
{visualizationMode === 'waveform' && ( | |||
<WaveSurferCanvas | |||
className="sm:absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-[#000000]" | |||
className="sm:absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-primary/10" | |||
ref={mediaControllerRef} | |||
data-testid="preview" | |||
barWidth={1} | |||
barGap={1} | |||
waveColor="rgb(199 138 179)" | |||
progressColor="rgb(255 153 0)" | |||
waveColor={`rgb(${theme.primary.split(' ').map((c) => Math.floor(Number(c) / 2)).join(' ')})`} | |||
progressColor={`rgb(${theme.primary})`} | |||
interact | |||
/> | |||
)} | |||
<div className="flex gap-4 absolute top-0 right-0 z-[2] px-4"> | |||
<label | |||
className={clsx( | |||
'h-12 flex text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none', | |||
'focus:outline-0', | |||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||
'h-12 flex items-center justify-center leading-none gap-4 select-none', | |||
)} | |||
> | |||
<input | |||
type="radio" | |||
name="visualizationMode" | |||
value="waveform" | |||
className="sr-only" | |||
className="sr-only peer/waveform" | |||
onChange={handleVisualizationModeChange} | |||
/> | |||
<span | |||
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded" | |||
className={clsx( | |||
'block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded text-primary', | |||
'peer-focus/waveform:text-secondary', | |||
'peer-active/waveform:text-tertiary', | |||
'peer-disabled/waveform:text-primary peer-disabled/waveform:cursor-not-allowed peer-disabled/waveform:opacity-50', | |||
)} | |||
> | |||
Waveform | |||
</span> | |||
</label> | |||
<label | |||
className={clsx( | |||
'h-12 flex text-primary disabled:text-primary focus:text-secondary active:text-tertiary items-center justify-center leading-none gap-4 select-none', | |||
'focus:outline-0', | |||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||
'h-12 flex items-center justify-center leading-none gap-4 select-none', | |||
)} | |||
> | |||
<input | |||
type="radio" | |||
name="visualizationMode" | |||
value="spectrum" | |||
className="sr-only" | |||
className="sr-only peer/waveform" | |||
onChange={handleVisualizationModeChange} | |||
/> | |||
<span | |||
className="block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded" | |||
className={clsx( | |||
'block uppercase font-bold h-[1.1em] w-full whitespace-nowrap overflow-hidden text-ellipsis font-semi-expanded text-primary', | |||
'peer-focus/waveform:text-secondary', | |||
'peer-active/waveform:text-tertiary', | |||
'peer-disabled/waveform:text-primary peer-disabled/waveform:cursor-not-allowed peer-disabled/waveform:opacity-50', | |||
)} | |||
> | |||
Spectrum | |||
</span> | |||
@@ -153,19 +161,59 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
</div> | |||
</div> | |||
{enhanced && ( | |||
<div className="w-full flex-shrink-0 h-10 flex gap-4 relative"> | |||
<div className="w-full flex-shrink-0 h-10 flex gap-4 items-center"> | |||
<button | |||
className="w-10 h-full flex-shrink-0 text-primary" | |||
className={clsx( | |||
'w-10 h-full flex-shrink-0 text-primary flex items-center justify-center', | |||
'focus:text-secondary focus:outline-0', | |||
'active:text-tertiary', | |||
'disabled:text-primary disabled:cursor-not-allowed disabled:opacity-50', | |||
)} | |||
type="submit" | |||
name="action" | |||
value="togglePlayback" | |||
form={formId} | |||
> | |||
{isPlaying ? '⏸' : '▶'} | |||
{ | |||
isPlaying | |||
? ( | |||
<svg | |||
aria-label="Pause" | |||
viewBox="0 0 24 24" | |||
className="w-6 h-6 fill-none stroke-current stroke-2" | |||
strokeLinecap="round" | |||
strokeLinejoin="round" | |||
> | |||
<rect | |||
x="6" | |||
y="4" | |||
width="4" | |||
height="16" | |||
/> | |||
<rect | |||
x="14" | |||
y="4" | |||
width="4" | |||
height="16" | |||
/> | |||
</svg> | |||
) | |||
: ( | |||
<svg | |||
aria-label="Play" | |||
viewBox="0 0 24 24" | |||
className="w-6 h-6 fill-none stroke-current stroke-2" | |||
strokeLinecap="round" | |||
strokeLinejoin="round" | |||
> | |||
<polygon points="5 3 19 12 5 21 5 3" /> | |||
</svg> | |||
) | |||
} | |||
</button> | |||
<div className="flex-auto w-full flex items-center gap-2 text-sm relative"> | |||
<button | |||
className="absolute overflow-hidden w-12 opacity-0 h-10" | |||
className="absolute overflow-hidden w-12 opacity-0 h-10 peer/seek" | |||
title="Toggle Seek Time Count Mode" | |||
type="submit" | |||
name="action" | |||
@@ -174,8 +222,13 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
> | |||
Toggle Seek Time Count Mode | |||
</button> | |||
<span | |||
className="before:block before:content-[attr(title)] contents tabular-nums" | |||
<div | |||
className={clsx( | |||
'before:block before:content-[attr(title)] contents tabular-nums flex-auto text-primary font-bold', | |||
'peer-focus/seek:text-secondary peer-focus/seek:outline-0', | |||
'peer-active/seek:text-tertiary', | |||
'peer-disabled/seek:text-primary \'peer-disabled/seek:cursor-not-allowed \'peer-disabled/seek:opacity-50' | |||
)} | |||
title={ | |||
`${ | |||
isSeekTimeCountingDown ? '−' : '+' | |||
@@ -186,9 +239,8 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
}` | |||
} | |||
> | |||
<input | |||
type="range" | |||
className="flex-auto w-full tabular-nums" | |||
<Slider | |||
className="w-full bg-negative text-base" | |||
ref={seekRef} | |||
onMouseDown={startSeek} | |||
onMouseUp={endSeek} | |||
@@ -196,13 +248,12 @@ export const AudioFilePreview = React.forwardRef<AudioFilePreviewDerivedComponen | |||
defaultValue="0" | |||
step="any" | |||
/> | |||
</span> | |||
<span> | |||
</div> | |||
<span className="tabular-nums font-bold"> | |||
{formatSecondsDurationConcise(durationDisplay)} | |||
</span> | |||
</div> | |||
<input | |||
type="range" | |||
<Slider | |||
ref={volumeRef} | |||
max={1} | |||
min={0} | |||
@@ -3,6 +3,7 @@ import {augmentVideoFile, getMimeTypeDescription} from '@/utils/blob'; | |||
import {formatFileSize, formatNumeral, formatSecondsDurationConcise} from '@/utils/numeral'; | |||
import {useAugmentedFile, useMediaControls} from '@tesseract-design/web-blob-react'; | |||
import clsx from 'clsx'; | |||
import {Slider} from '@tesseract-design/web-number-react'; | |||
type VideoFilePreviewDerivedComponent = HTMLVideoElement; | |||
@@ -72,37 +73,91 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||
data-testid="preview" | |||
> | |||
<div className="w-full flex-auto relative"> | |||
<video | |||
{...etcProps} | |||
className="sm:absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-[#000000]" | |||
ref={mediaControllerRef} | |||
onLoadedMetadata={refreshControls} | |||
onDurationChange={refreshControls} | |||
onEnded={reset} | |||
onTimeUpdate={updateSeekFromPlayback} | |||
data-testid="preview" | |||
controls={!enhanced} | |||
> | |||
<source | |||
src={augmentedFile.metadata.previewUrl} | |||
type={augmentedFile.type} | |||
/> | |||
</video> | |||
<video | |||
{...etcProps} | |||
className="sm:absolute w-full sm:h-full top-0 left-0 block object-center object-contain flex-auto aspect-video sm:aspect-auto bg-primary/10" | |||
ref={mediaControllerRef} | |||
onLoadedMetadata={refreshControls} | |||
onDurationChange={refreshControls} | |||
onEnded={reset} | |||
onTimeUpdate={updateSeekFromPlayback} | |||
data-testid="preview" | |||
controls={!enhanced} | |||
> | |||
<source | |||
src={augmentedFile.metadata.previewUrl} | |||
type={augmentedFile.type} | |||
/> | |||
</video> | |||
<button | |||
className="absolute w-full h-full top-0 left-0" | |||
type="submit" | |||
name="action" | |||
value="togglePlayback" | |||
form={formId} | |||
tabIndex={-1} | |||
> | |||
<span className="sr-only"> | |||
{isPlaying ? 'Pause' : 'Play'} | |||
</span> | |||
</button> | |||
</div> | |||
{enhanced && ( | |||
<div className="w-full flex-shrink-0 h-10 flex gap-4"> | |||
<div className="w-full flex-shrink-0 h-10 flex gap-4 items-center"> | |||
<button | |||
className="w-10 h-full flex-shrink-0 text-primary" | |||
className={ | |||
clsx( | |||
'w-10 h-full flex-shrink-0 text-primary flex items-center justify-center', | |||
'focus:text-secondary focus:outline-0', | |||
'active:text-tertiary', | |||
'disabled:text-primary disabled:cursor-not-allowed disabled:opacity-50' | |||
) | |||
} | |||
type="submit" | |||
name="action" | |||
value="togglePlayback" | |||
form={formId} | |||
> | |||
{isPlaying ? '⏸' : '▶'} | |||
{ | |||
isPlaying | |||
? ( | |||
<svg | |||
aria-label="Pause" | |||
viewBox="0 0 24 24" | |||
className="w-6 h-6 fill-none stroke-current stroke-2" | |||
strokeLinecap="round" | |||
strokeLinejoin="round" | |||
> | |||
<rect | |||
x="6" | |||
y="4" | |||
width="4" | |||
height="16" | |||
/> | |||
<rect | |||
x="14" | |||
y="4" | |||
width="4" | |||
height="16" | |||
/> | |||
</svg> | |||
) | |||
: ( | |||
<svg | |||
aria-label="Play" | |||
viewBox="0 0 24 24" | |||
className="w-6 h-6 fill-none stroke-current stroke-2" | |||
strokeLinecap="round" | |||
strokeLinejoin="round" | |||
> | |||
<polygon points="5 3 19 12 5 21 5 3" /> | |||
</svg> | |||
) | |||
} | |||
</button> | |||
<div className="flex-auto w-full flex items-center gap-2 text-sm relative"> | |||
<button | |||
className="absolute overflow-hidden w-12 opacity-0 h-10" | |||
className="absolute overflow-hidden w-12 opacity-0 h-10 peer/seek" | |||
title="Toggle Seek Time Count Mode" | |||
type="submit" | |||
name="action" | |||
@@ -111,8 +166,13 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||
> | |||
Toggle Seek Time Count Mode | |||
</button> | |||
<span | |||
className="before:block before:content-[attr(title)] contents tabular-nums" | |||
<div | |||
className={clsx( | |||
'before:block before:content-[attr(title)] contents tabular-nums flex-auto text-primary font-bold', | |||
'peer-focus/seek:text-secondary peer-focus/seek:outline-0', | |||
'peer-active/seek:text-tertiary', | |||
'peer-disabled/seek:text-primary \'peer-disabled/seek:cursor-not-allowed \'peer-disabled/seek:opacity-50' | |||
)} | |||
title={ | |||
`${ | |||
isSeekTimeCountingDown ? '−' : '+' | |||
@@ -123,9 +183,8 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||
}` | |||
} | |||
> | |||
<input | |||
type="range" | |||
className="flex-auto w-full tabular-nums" | |||
<Slider | |||
className="w-full bg-negative text-base" | |||
ref={seekRef} | |||
onMouseDown={startSeek} | |||
onMouseUp={endSeek} | |||
@@ -133,13 +192,12 @@ export const VideoFilePreview = React.forwardRef<VideoFilePreviewDerivedComponen | |||
defaultValue="0" | |||
step="any" | |||
/> | |||
</div> | |||
<span className="tabular-nums font-bold"> | |||
{formatSecondsDurationConcise(durationDisplay)} | |||
</span> | |||
<span> | |||
{formatSecondsDurationConcise(durationDisplay)} | |||
</span> | |||
</div> | |||
<input | |||
type="range" | |||
<Slider | |||
ref={volumeRef} | |||
max={1} | |||
min={0} | |||
@@ -0,0 +1,36 @@ | |||
import * as React from 'react'; | |||
import clsx from 'clsx'; | |||
type SliderDerivedComponent = HTMLInputElement; | |||
export interface SliderProps extends Omit<React.HTMLProps<HTMLInputElement>, 'type'> { | |||
} | |||
export const Slider = React.forwardRef<SliderDerivedComponent, SliderProps>(({ | |||
className, | |||
style, | |||
...etcProps | |||
}, forwardedRef) => { | |||
return ( | |||
<div | |||
className={clsx( | |||
className, | |||
)} | |||
style={style} | |||
> | |||
<input | |||
{...etcProps} | |||
ref={forwardedRef} | |||
type="range" | |||
className={clsx( | |||
'w-full h-full bg-inherit slider block text-primary ring-secondary/50', | |||
'focus:text-secondary focus:outline-0 focus:ring-4', | |||
'active:text-tertiary active:ring-tertiary/50', | |||
)} | |||
/> | |||
</div> | |||
) | |||
}); | |||
Slider.displayName = 'Slider'; |
@@ -0,0 +1 @@ | |||
export * from './components/Slider'; |
@@ -1,9 +1,14 @@ | |||
import { Html, Head, Main, NextScript } from 'next/document' | |||
import theme from '@/styles/theme' | |||
export default function Document() { | |||
return ( | |||
<Html lang="en" className="bg-negative text-positive"> | |||
<Head /> | |||
<Head> | |||
<style> | |||
{`:root{${Object.entries(theme).map(([key, value]) => `--color-${key}: ${value};`).join('')}}`} | |||
</style> | |||
</Head> | |||
<body> | |||
<Main /> | |||
<NextScript /> | |||
@@ -1,41 +1,72 @@ | |||
@tailwind base; | |||
@tailwind utilities; | |||
@layer base { | |||
:root { | |||
--color-shade: 0 0 0; | |||
--color-negative: 34 34 34; | |||
--color-positive: 238 238 238; | |||
--color-primary: 199 138 179; | |||
--color-secondary: 255 153 0; | |||
--color-tertiary: 215 95 75; | |||
--color-code-number: 116 249 94; | |||
--color-code-keyword: 255 67 137; | |||
--color-code-type: 80 151 210; | |||
--color-code-instance-attribute: 118 167 210; | |||
--color-code-function: 103 194 82; | |||
--color-code-parameter: 145 94 194; | |||
--color-code-property: 255 161 201; | |||
--color-code-string: 238 211 113; | |||
--color-code-variable: 139 194 117; | |||
--color-code-regexp: 116 167 43; | |||
--color-code-url: 0 153 204; | |||
--color-code-global: 194 128 80; | |||
@layer utilities { | |||
.font-condensed { | |||
font-stretch: condensed; | |||
} | |||
.font-semi-condensed { | |||
font-stretch: semi-condensed; | |||
} | |||
.font-expanded { | |||
font-stretch: expanded; | |||
} | |||
.font-semi-expanded { | |||
font-stretch: semi-expanded; | |||
} | |||
} | |||
.slider { | |||
@apply rounded-full; | |||
appearance: none; | |||
cursor: pointer; | |||
position: relative; | |||
overflow: hidden; | |||
height: 1em; | |||
color: rgb(var(--color-primary)); | |||
} | |||
.slider::before { | |||
@apply rounded-full; | |||
content: ''; | |||
display: block; | |||
position: absolute; | |||
background-color: currentColor; | |||
opacity: 0.5; | |||
width: 100%; | |||
height: 50%; | |||
top: 25%; | |||
} | |||
.slider::-webkit-slider-container { | |||
height: 100%; | |||
overflow: hidden; | |||
box-sizing: border-box; | |||
} | |||
.font-condensed { | |||
font-stretch: condensed; | |||
.slider::-webkit-slider-runnable-track { | |||
appearance: none; | |||
height: 100%; | |||
} | |||
.font-semi-condensed { | |||
font-stretch: semi-condensed; | |||
.slider::-webkit-slider-thumb { | |||
@apply rounded-full; | |||
background-color: currentColor; | |||
appearance: none; | |||
height: 100%; | |||
aspect-ratio: 1 / 1; | |||
z-index: 1; | |||
position: relative; | |||
box-shadow: -100000.5em 0 0 100000em rgb(var(--color-primary) / 50%); | |||
} | |||
.font-expanded { | |||
font-stretch: expanded; | |||
.slider:focus::-webkit-slider-thumb { | |||
box-shadow: -100000.5em 0 0 100000em rgb(var(--color-secondary) / 50%); | |||
} | |||
.font-semi-expanded { | |||
font-stretch: semi-expanded; | |||
.slider:active::-webkit-slider-thumb { | |||
box-shadow: -100000.5em 0 0 100000em rgb(var(--color-tertiary) / 50%); | |||
} |
@@ -0,0 +1,22 @@ | |||
const theme = { | |||
"shade": "0 0 0", | |||
"negative": "34 34 34", | |||
"positive": "238 238 238", | |||
"primary": "199 138 179", | |||
"secondary": "255 153 0", | |||
"tertiary": "215 95 75", | |||
"code-number": "116 249 94", | |||
"code-keyword": "255 67 137", | |||
"code-type": "80 151 210", | |||
"code-instance-attribute": "118 167 210", | |||
"code-function": "103 194 82", | |||
"code-parameter": "145 94 194", | |||
"code-property": "255 161 201", | |||
"code-string": "238 211 113", | |||
"code-variable": "139 194 117", | |||
"code-regexp": "116 167 43", | |||
"code-url": "0 153 204", | |||
"code-global": "194 128 80" | |||
} as const; | |||
export default theme; |
@@ -1,3 +1,5 @@ | |||
const defaultTheme = require('tailwindcss/defaultTheme') | |||
/** @type {import('tailwindcss').Config} */ | |||
module.exports = { | |||
content: [ | |||
@@ -7,7 +9,8 @@ module.exports = { | |||
], | |||
theme: { | |||
fontFamily: { | |||
sans: ['Encode Sans Semi Expanded', 'Encode Sans', 'sans-serif'], | |||
sans: ['Encode Sans', ...defaultTheme.fontFamily.sans], | |||
mono: ['MonoLisa', 'mononoki', ...defaultTheme.fontFamily.mono], | |||
}, | |||
colors: { | |||
'shade': 'rgb(var(--color-shade))', | |||
@@ -16,6 +16,7 @@ | |||
"incremental": true, | |||
"paths": { | |||
"@/*": ["./src/*"], | |||
"tailwind.config": ["./tailwind.config.js"], | |||
"@tesseract-design/web-base-button": ["./src/base/button"], | |||
"@tesseract-design/web-base-selectcontrol": ["./src/base/selectcontrol"], | |||
"@tesseract-design/web-base-textcontrol": ["./src/base/textcontrol"], | |||
@@ -24,6 +25,7 @@ | |||
"@tesseract-design/web-freeform-react": ["./src/categories/freeform/react"], | |||
"@tesseract-design/web-information-react": ["./src/categories/information/react"], | |||
"@tesseract-design/web-option-react": ["./src/categories/option/react"], | |||
"@tesseract-design/web-number-react": ["./src/categories/number/react"], | |||
"@tesseract-design/web-navigation-react": ["./src/categories/navigation/react"], | |||
} | |||
}, | |||