diff --git a/src/internal/Icons/IconDownloadVideo.tsx b/src/internal/Icons/IconDownloadVideo.tsx new file mode 100644 index 00000000..a2239674 --- /dev/null +++ b/src/internal/Icons/IconDownloadVideo.tsx @@ -0,0 +1,13 @@ +import type {FC, SVGProps} from 'react'; +import Icon from '@phosphor-icons/core/assets/fill/box-arrow-down-fill.svg?react'; +import classNames from 'classnames'; + +import classes from './Icon.module.css'; + +type Props = SVGProps & { + className?: string; +}; + +export const IconDownloadVideo: FC = ({className, ...restProps}) => { + return ; +}; diff --git a/src/internal/Icons/IconFullscreen.tsx b/src/internal/Icons/IconFullscreen.tsx new file mode 100644 index 00000000..33e6ece6 --- /dev/null +++ b/src/internal/Icons/IconFullscreen.tsx @@ -0,0 +1,13 @@ +import type {FC, SVGProps} from 'react'; +import Icon from '@phosphor-icons/core/assets/fill/frame-corners-fill.svg?react'; +import classNames from 'classnames'; + +import classes from './Icon.module.css'; + +type Props = SVGProps & { + className?: string; +}; + +export const IconFullscreen: FC = ({className, ...restProps}) => { + return ; +}; diff --git a/src/internal/Icons/IconPause.tsx b/src/internal/Icons/IconPause.tsx new file mode 100644 index 00000000..eea2895f --- /dev/null +++ b/src/internal/Icons/IconPause.tsx @@ -0,0 +1,13 @@ +import type {FC, SVGProps} from 'react'; +import Icon from '@phosphor-icons/core/assets/fill/pause-fill.svg?react'; +import classNames from 'classnames'; + +import classes from './Icon.module.css'; + +type Props = SVGProps & { + className?: string; +}; + +export const IconPause: FC = ({className, ...restProps}) => { + return ; +}; diff --git a/src/internal/Icons/IconPictureInPicture.tsx b/src/internal/Icons/IconPictureInPicture.tsx new file mode 100644 index 00000000..9188113c --- /dev/null +++ b/src/internal/Icons/IconPictureInPicture.tsx @@ -0,0 +1,13 @@ +import type {FC, SVGProps} from 'react'; +import Icon from '@phosphor-icons/core/assets/fill/picture-in-picture-fill.svg?react'; +import classNames from 'classnames'; + +import classes from './Icon.module.css'; + +type Props = SVGProps & { + className?: string; +}; + +export const IconPictureInPicture: FC = ({className, ...restProps}) => { + return ; +}; diff --git a/src/internal/Icons/IconPlay.tsx b/src/internal/Icons/IconPlay.tsx new file mode 100644 index 00000000..a3f995ca --- /dev/null +++ b/src/internal/Icons/IconPlay.tsx @@ -0,0 +1,13 @@ +import type {FC, SVGProps} from 'react'; +import Icon from '@phosphor-icons/core/assets/fill/play-fill.svg?react'; +import classNames from 'classnames'; + +import classes from './Icon.module.css'; + +type Props = SVGProps & { + className?: string; +}; + +export const IconPlay: FC = ({className, ...restProps}) => { + return ; +}; diff --git a/src/internal/Icons/IconSpeed.tsx b/src/internal/Icons/IconSpeed.tsx new file mode 100644 index 00000000..b68d24db --- /dev/null +++ b/src/internal/Icons/IconSpeed.tsx @@ -0,0 +1,13 @@ +import type {FC, SVGProps} from 'react'; +import Icon from '@phosphor-icons/core/assets/fill/box-arrow-down-fill.svg?react'; +import classNames from 'classnames'; + +import classes from './Icon.module.css'; + +type Props = SVGProps & { + className?: string; +}; + +export const IconSpeed: FC = ({className, ...restProps}) => { + return ; +}; diff --git a/src/internal/Icons/IconVideo.tsx b/src/internal/Icons/IconVideo.tsx new file mode 100644 index 00000000..9bd3b693 --- /dev/null +++ b/src/internal/Icons/IconVideo.tsx @@ -0,0 +1,13 @@ +import type {FC, SVGProps} from 'react'; +import Icon from '@phosphor-icons/core/assets/fill/film-slate-fill.svg?react'; +import classNames from 'classnames'; + +import classes from './Icon.module.css'; + +type Props = SVGProps & { + className?: string; +}; + +export const IconVideo: FC = ({className, ...restProps}) => { + return ; +}; diff --git a/src/internal/Icons/IconVolume.tsx b/src/internal/Icons/IconVolume.tsx new file mode 100644 index 00000000..fe8a9072 --- /dev/null +++ b/src/internal/Icons/IconVolume.tsx @@ -0,0 +1,13 @@ +import type {FC, SVGProps} from 'react'; +import Icon from '@phosphor-icons/core/assets/fill/speaker-high-fill.svg?react'; +import classNames from 'classnames'; + +import classes from './Icon.module.css'; + +type Props = SVGProps & { + className?: string; +}; + +export const IconVolume: FC = ({className, ...restProps}) => { + return ; +}; diff --git a/src/internal/Icons/IconVolumeOff.tsx b/src/internal/Icons/IconVolumeOff.tsx new file mode 100644 index 00000000..85e22d4e --- /dev/null +++ b/src/internal/Icons/IconVolumeOff.tsx @@ -0,0 +1,13 @@ +import type {FC, SVGProps} from 'react'; +import Icon from '@phosphor-icons/core/assets/fill/speaker-slash-fill.svg?react'; +import classNames from 'classnames'; + +import classes from './Icon.module.css'; + +type Props = SVGProps & { + className?: string; +}; + +export const IconVolumeOff: FC = ({className, ...restProps}) => { + return ; +}; diff --git a/src/internal/Icons/index.ts b/src/internal/Icons/index.ts index 30d731c2..bd219a61 100644 --- a/src/internal/Icons/index.ts +++ b/src/internal/Icons/index.ts @@ -45,3 +45,11 @@ export {IconErrorOutline} from './IconErrorOutline.tsx'; export {IconSortOff} from './IconSortOff.tsx'; export {IconColumns} from './IconColumns.tsx'; export {IconNumeric} from './IconNumeric.tsx'; +export {IconPlay} from './IconPlay.tsx'; +export {IconPause} from './IconPause.tsx'; +export {IconVolume} from './IconVolume.tsx'; +export {IconVolumeOff} from './IconVolumeOff.tsx'; +export {IconFullscreen} from './IconFullscreen.tsx'; +export {IconPictureInPicture} from './IconPictureInPicture.tsx'; +export {IconDownloadVideo} from './IconDownloadVideo.tsx'; +export {IconVideo} from './IconVideo.tsx'; diff --git a/src/lib/InputRange/InputRange.stories.tsx b/src/lib/InputRange/InputRange.stories.tsx index 9682df74..68695d5d 100644 --- a/src/lib/InputRange/InputRange.stories.tsx +++ b/src/lib/InputRange/InputRange.stories.tsx @@ -27,6 +27,7 @@ const meta = { step: 1, scaleUnit: 'F', disabled: false, + displayScale: true, }, argTypes: { value: {control: 'text'}, diff --git a/src/lib/InputRange/InputRange.tsx b/src/lib/InputRange/InputRange.tsx index d48618fd..ab156f44 100644 --- a/src/lib/InputRange/InputRange.tsx +++ b/src/lib/InputRange/InputRange.tsx @@ -23,6 +23,10 @@ export type Props = DataAttributes & prefix?: FC<{className?: string} & SVGProps & unknown>; bars?: number; scaleUnit?: string; + /** + * Enable to display scale below input + */ + displayScale?: boolean; }; const createOptions = ({ @@ -72,6 +76,7 @@ export const InputRange = forwardRef( revalidateOnFormChange, validation, errorMessage, + displayScale, ...nativeProps }, ref @@ -143,9 +148,11 @@ export const InputRange = forwardRef( min={min} max={max} /> - - {createOptions({min: Number(min), max: Number(max), bars, scaleUnit})} - + {displayScale && ( + + {createOptions({min: Number(min), max: Number(max), bars, scaleUnit})} + + )} {/* TODO: add htmlFor when CSS vars hook supports it */} diff --git a/src/lib/Video/Video.mdx b/src/lib/Video/Video.mdx new file mode 100644 index 00000000..b08b7541 --- /dev/null +++ b/src/lib/Video/Video.mdx @@ -0,0 +1,24 @@ +import { Meta, ArgTypes, Story, Canvas, Source, Markdown, Primary } from "@storybook/blocks"; +import {Video} from './Video.tsx'; +import * as VideoStories from "./Video.stories.tsx"; + + + +# Video + +## Description + +`Video` component plays provided video file using Video Embed element. + +## Imports + +{` +\`\`\`ts +import {Video} from 'koval-ui'; +\`\`\` +`} + + + + + diff --git a/src/lib/Video/Video.module.css b/src/lib/Video/Video.module.css new file mode 100644 index 00000000..e542a02f --- /dev/null +++ b/src/lib/Video/Video.module.css @@ -0,0 +1,351 @@ +@import url("@/internal/inputs/stateSelectorsInteractive.css"); +@import url("@/lib/Layout/customMedia.css"); + +.vars { + --width: 0px; + --height: 0px; +} + +.container { + --icon-size: calc(var(--kg-size-unit) * 3); + --font-size: var(--kg-font-size-small); + + @media (--viewport-sm) { + --icon-size: calc(var(--kg-size-unit) * 4); + --font-size: var(--kg-font-size-medium); + } + + aspect-ratio: calc(var(--width) / var(--height)); + height: auto; + max-width: 100%; + position: relative; + width: var(--width); + + &:hover { + & .overlay-title, + & .overlay-controls { + opacity: 1; + } + } +} + +.video { + max-height: 100%; + max-width: 100%; +} + +@keyframes loading { + to { + background-position-x: -20%; + } +} + +.overlay-button { + --button-size: calc(var(--kg-size-unit) * 10); + + height: var(--button-size); + left: 50%; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: var(--button-size); + + & button { + align-items: center; + background-color: var(--kg-color-re); + border-radius: calc(var(--button-size) / 2); + cursor: pointer; + display: flex; + height: var(--button-size); + justify-content: center; + width: var(--button-size); + + &:hover { + background-color: var(--kg-color-do); + } + + &:disabled { + --loading-color: color-mix( + in srgb, + var(--kg-background-300) 66%, + var(--kg-background-100) + ); + + animation: var(--kg-time-xl) loading ease-in-out infinite; + background: linear-gradient( + 90deg, + rgb(222 222 222 / 0%) 40%, + rgb(222 222 222 / 50%) 50%, + rgb(222 222 222 / 0%) 60% + ) + var(--loading-color); + background-color: var(--loading-color); + background-position-x: 180%; + background-size: 200% 100%; + cursor: progress; + } + + &:active { + & .icon { + transform: translate(2px, 2px); + } + } + } + + & .icon { + height: calc(var(--kg-size-unit) * 6); + width: calc(var(--kg-size-unit) * 6); + } +} + +.overlay-title { + align-items: center; + backdrop-filter: blur(6px); + background-color: color-mix(in srgb, var(--kg-color-re) 66%, transparent); + color: var(--kg-background-000); + display: flex; + font-size: var(--font-size); + gap: var(--kg-size-unit); + left: 0; + opacity: 0; + padding: calc(var(--kg-size-unit) * 2); + position: absolute; + right: 0; + text-shadow: var(--kg-text-shadow); + top: 0; + transition: opacity var(--kg-time-sm) ease-out; +} + +.overlay-controls { + backdrop-filter: blur(6px); + background-color: color-mix(in srgb, var(--kg-color-re) 66%, transparent); + bottom: 0; + display: flex; + flex-direction: column; + gap: var(--kg-size-unit); + left: 0; + opacity: 0; + padding: var(--kg-size-unit) calc(var(--kg-size-unit) * 2); + position: absolute; + right: 0; + transition: opacity var(--kg-time-sm) ease-out; + + @media (--viewport-sm) { + gap: calc(var(--kg-size-unit) * 2); + padding: calc(var(--kg-size-unit) * 2); + } +} + +.buttons-container { + align-items: center; + display: flex; + + & .left { + align-items: center; + display: flex; + gap: calc(var(--kg-size-unit) * 2); + } + + & .right { + align-items: center; + display: flex; + gap: calc(var(--kg-size-unit) * 2); + margin-left: auto; + } +} + +.time-stamp { + align-items: center; + color: var(--kg-background-000); + display: flex; + gap: calc(var(--kg-size-unit) / 2); + text-shadow: var(--kg-text-shadow); + + & .time { + font-size: var(--font-size); + font-variant-numeric: tabular-nums; + } + + & .spacer { + font-size: var(--kg-font-size-large); + } +} + +.volume { + /* Don't display volume control on mobile devices (aka small screens). Device settings should be used instead */ + display: none; + + @media (--viewport-sm) { + align-items: center; + display: flex; + gap: var(--kg-size-unit); + } + + & .range { + width: calc(var(--kg-size-unit) * 16); + } +} + +.icon { + color: var(--kg-background-000); + filter: drop-shadow(var(--kg-text-shadow)); + height: var(--icon-size); + width: var(--icon-size); +} + +.button-big { + cursor: pointer; + + &:active { + & .icon { + transform: translate(1px, 1px); + } + } + + @media (--viewport-sm) { + background: var(--kg-color-re); + border-radius: calc(var(--kg-size-unit) * 4); + padding: calc(var(--kg-size-unit) * 1.5); + + &:hover { + background-color: var(--kg-color-do); + } + } +} + +.button { + cursor: pointer; + + &:active { + & .icon { + transform: translate(1px, 1px); + } + } + + &:hover { + & .icon { + color: var(--kg-color-mi); + } + } +} + +.timeline-container { + & .range { + --thumb-color: var(--kg-color-do); + + width: 100%; + } +} + +.range { + --track-height: calc(var(--kg-size-unit) / 3); + --slider-height: calc(var(--kg-size-unit) * 2); + --slider-width: var(--slider-height); + --track-color: var(--kg-background-000); + --track-color-active: var(--kg-color-mi); + --thumb-color: var(--kg-color-do); + --thumb-color-active: var(--kg-color-do); + + filter: drop-shadow(var(--kg-text-shadow)); + + &::-moz-range-track { + background: var(--track-color); + height: var(--track-height); + transition: background-color var(--kg-time-sm) ease-in-out; + } + + &::-webkit-slider-runnable-track { + appearance: none; + background: var(--track-color); + height: var(--track-height); + transition: background-color var(--kg-time-sm) ease-in-out; + } + + &::-webkit-slider-thumb { + appearance: none; + background-color: var(--thumb-color); + border: none; + border-radius: 50%; + cursor: grab; + height: var(--slider-height); + margin-top: calc(var(--track-height) / 2 - var(--slider-height) / 2); + transition: background-color var(--kg-time-sm) ease-in-out; + width: var(--slider-width); + } + + &::-moz-range-thumb { + background-color: var(--thumb-color); + border: none; + cursor: grab; + transition: background-color var(--kg-time-sm) ease-in-out; + } + + &:--hoverSelector { + &::-moz-range-track { + background: var(--track-color-active); + } + + &::-webkit-slider-runnable-track { + background: var(--track-color-active); + } + } + + &:--disabledSelector { + &::-moz-range-thumb { + background-color: var(--kg-background-100); + cursor: not-allowed; + } + + &::-webkit-slider-thumb { + background-color: var(--kg-background-100); + cursor: not-allowed; + } + + &::-moz-range-track { + background: var(--kg-background-100); + } + + &::-webkit-slider-runnable-track { + background: var(--kg-background-100); + } + } + + &:active:not(:--disabledSelector) { + &::-moz-range-thumb { + cursor: grabbing; + } + + &::-webkit-slider-thumb { + cursor: grabbing; + } + } + + &:active:not(:--disabledSelector), + &:--focusSelector { + &::-moz-range-thumb { + background-color: var(--thumb-color-active); + } + + &::-webkit-slider-thumb { + background-color: var(--thumb-color-active); + } + + &::-moz-range-track { + background: var(--track-color-active); + } + + &::-webkit-slider-runnable-track { + background: var(--track-color-active); + } + } + + &:--invalidSelector:focus { + &::-moz-range-track { + background: var(--kg-color-error); + } + + &::-webkit-slider-runnable-track { + background: var(--kg-color-error); + } + } +} diff --git a/src/lib/Video/Video.stories.tsx b/src/lib/Video/Video.stories.tsx new file mode 100644 index 00000000..73231607 --- /dev/null +++ b/src/lib/Video/Video.stories.tsx @@ -0,0 +1,118 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {fn} from '@storybook/test'; + +import {Video} from './Video.tsx'; +import poster from './poster.jpg'; + +const meta = { + title: 'Components/Video', + component: Video, + parameters: { + layout: 'fullscreen', + }, + args: { + width: 720, + height: 405, + title: 'Big Buck Bunny', + poster: poster, + loop: false, + muted: false, + autoPlay: false, + enablePictureInPicture: true, + enableDownload: true, + enableFullscreen: true, + showControls: true, + showTitle: true, + preload: 'auto', + onCanPlay: fn(), + onReady: fn(), + onError: fn(), + onPlay: fn(), + onPause: fn(), + }, + argTypes: { + width: { + control: 'number', + }, + src: { + table: { + disable: true, + }, + }, + sources: { + table: { + disable: true, + }, + }, + className: { + table: { + disable: true, + }, + }, + id: { + table: { + disable: true, + }, + }, + role: { + table: { + disable: true, + }, + }, + onCanPlay: { + table: { + disable: true, + }, + }, + onReady: { + table: { + disable: true, + }, + }, + onError: { + table: { + disable: true, + }, + }, + onPlay: { + table: { + disable: true, + }, + }, + onPause: { + table: { + disable: true, + }, + }, + }, +} as Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + name: 'Single source example', + render: args => { + return