feat: add storybook (#3152)
This commit is contained in:
34
.github/workflows/storybook.yaml
vendored
Normal file
34
.github/workflows/storybook.yaml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Storybook
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/storybook.yaml"
|
||||
- "packages/popcorntime-ui/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
chromatic-ui:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: ./.github/actions/init-env-node
|
||||
- name: Run Chromatic
|
||||
uses: chromaui/action@latest
|
||||
with:
|
||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
zip: true
|
||||
onlyChanged: true
|
||||
skip: "dependabot/**"
|
||||
workingDir: packages/popcorntime-ui
|
||||
autoAcceptChanges: false
|
||||
exitOnceUploaded: true
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -28,4 +28,6 @@ dist-ssr
|
||||
*.sw?
|
||||
.turbo
|
||||
.env
|
||||
.vite
|
||||
.vite
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["apps/**", "!**/dist", "!**/tauri/types.ts"]
|
||||
"includes": [
|
||||
"apps/**",
|
||||
"packages/**",
|
||||
"!**/dist",
|
||||
"!**/tauri/types.ts",
|
||||
"!packages/translator/*",
|
||||
"!**/*.stories.tsx"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -1,138 +1,138 @@
|
||||
export default {
|
||||
AE: {
|
||||
default: "ar",
|
||||
languages: ["ar", "fa", "ur", "hi"],
|
||||
},
|
||||
US: {
|
||||
default: "en",
|
||||
languages: ["en"],
|
||||
},
|
||||
DE: {
|
||||
default: "de",
|
||||
languages: ["de"],
|
||||
},
|
||||
GB: {
|
||||
default: "en",
|
||||
languages: ["en"],
|
||||
},
|
||||
CA: {
|
||||
default: "en",
|
||||
languages: ["en", "fr"],
|
||||
},
|
||||
FR: {
|
||||
default: "fr",
|
||||
languages: ["fr"],
|
||||
},
|
||||
BE: {
|
||||
default: "fr",
|
||||
languages: ["fr", "nl", "de"],
|
||||
},
|
||||
AT: {
|
||||
default: "de",
|
||||
languages: ["de"],
|
||||
},
|
||||
AU: {
|
||||
default: "en",
|
||||
languages: ["en"],
|
||||
},
|
||||
SE: {
|
||||
default: "sv",
|
||||
languages: ["sv"],
|
||||
},
|
||||
IE: {
|
||||
default: "en",
|
||||
languages: ["en", "ga"],
|
||||
},
|
||||
FI: {
|
||||
default: "fi",
|
||||
languages: ["fi", "sv"],
|
||||
},
|
||||
NL: {
|
||||
default: "nl",
|
||||
languages: ["nl"],
|
||||
},
|
||||
IL: {
|
||||
default: "he",
|
||||
languages: ["he", "ar"],
|
||||
},
|
||||
NO: {
|
||||
default: "no",
|
||||
languages: ["no"],
|
||||
},
|
||||
DK: {
|
||||
default: "da",
|
||||
languages: ["da"],
|
||||
},
|
||||
CH: {
|
||||
default: "de",
|
||||
languages: ["de", "fr", "it"],
|
||||
},
|
||||
IT: {
|
||||
default: "it",
|
||||
languages: ["it"],
|
||||
},
|
||||
ES: {
|
||||
default: "es",
|
||||
languages: ["es"],
|
||||
},
|
||||
JP: {
|
||||
default: "ja",
|
||||
languages: ["ja"],
|
||||
},
|
||||
PL: {
|
||||
default: "pl",
|
||||
languages: ["pl"],
|
||||
},
|
||||
MX: {
|
||||
default: "es",
|
||||
languages: ["es"],
|
||||
},
|
||||
IN: {
|
||||
default: "hi",
|
||||
languages: ["hi", "en"],
|
||||
},
|
||||
TR: {
|
||||
default: "tr",
|
||||
languages: ["tr"],
|
||||
},
|
||||
NZ: {
|
||||
default: "en",
|
||||
languages: ["en"],
|
||||
},
|
||||
DZ: {
|
||||
default: "ar",
|
||||
languages: ["ar", "fr"],
|
||||
},
|
||||
EE: {
|
||||
default: "et",
|
||||
languages: ["et"],
|
||||
},
|
||||
EG: {
|
||||
default: "ar",
|
||||
languages: ["ar"],
|
||||
},
|
||||
GR: {
|
||||
default: "el",
|
||||
languages: ["el"],
|
||||
},
|
||||
PT: {
|
||||
default: "pt",
|
||||
languages: ["pt"],
|
||||
},
|
||||
BR: {
|
||||
default: "pt",
|
||||
languages: ["pt"],
|
||||
},
|
||||
RS: {
|
||||
default: "sr",
|
||||
languages: ["sr"],
|
||||
},
|
||||
SA: {
|
||||
default: "ar",
|
||||
languages: ["ar"],
|
||||
},
|
||||
MA: {
|
||||
default: "ar",
|
||||
languages: ["ar", "fr"],
|
||||
},
|
||||
} as const;
|
||||
AE: {
|
||||
default: "ar",
|
||||
languages: ["ar", "fa", "ur", "hi"],
|
||||
},
|
||||
US: {
|
||||
default: "en",
|
||||
languages: ["en"],
|
||||
},
|
||||
DE: {
|
||||
default: "de",
|
||||
languages: ["de"],
|
||||
},
|
||||
GB: {
|
||||
default: "en",
|
||||
languages: ["en"],
|
||||
},
|
||||
CA: {
|
||||
default: "en",
|
||||
languages: ["en", "fr"],
|
||||
},
|
||||
FR: {
|
||||
default: "fr",
|
||||
languages: ["fr"],
|
||||
},
|
||||
BE: {
|
||||
default: "fr",
|
||||
languages: ["fr", "nl", "de"],
|
||||
},
|
||||
AT: {
|
||||
default: "de",
|
||||
languages: ["de"],
|
||||
},
|
||||
AU: {
|
||||
default: "en",
|
||||
languages: ["en"],
|
||||
},
|
||||
SE: {
|
||||
default: "sv",
|
||||
languages: ["sv"],
|
||||
},
|
||||
IE: {
|
||||
default: "en",
|
||||
languages: ["en", "ga"],
|
||||
},
|
||||
FI: {
|
||||
default: "fi",
|
||||
languages: ["fi", "sv"],
|
||||
},
|
||||
NL: {
|
||||
default: "nl",
|
||||
languages: ["nl"],
|
||||
},
|
||||
IL: {
|
||||
default: "he",
|
||||
languages: ["he", "ar"],
|
||||
},
|
||||
NO: {
|
||||
default: "no",
|
||||
languages: ["no"],
|
||||
},
|
||||
DK: {
|
||||
default: "da",
|
||||
languages: ["da"],
|
||||
},
|
||||
CH: {
|
||||
default: "de",
|
||||
languages: ["de", "fr", "it"],
|
||||
},
|
||||
IT: {
|
||||
default: "it",
|
||||
languages: ["it"],
|
||||
},
|
||||
ES: {
|
||||
default: "es",
|
||||
languages: ["es"],
|
||||
},
|
||||
JP: {
|
||||
default: "ja",
|
||||
languages: ["ja"],
|
||||
},
|
||||
PL: {
|
||||
default: "pl",
|
||||
languages: ["pl"],
|
||||
},
|
||||
MX: {
|
||||
default: "es",
|
||||
languages: ["es"],
|
||||
},
|
||||
IN: {
|
||||
default: "hi",
|
||||
languages: ["hi", "en"],
|
||||
},
|
||||
TR: {
|
||||
default: "tr",
|
||||
languages: ["tr"],
|
||||
},
|
||||
NZ: {
|
||||
default: "en",
|
||||
languages: ["en"],
|
||||
},
|
||||
DZ: {
|
||||
default: "ar",
|
||||
languages: ["ar", "fr"],
|
||||
},
|
||||
EE: {
|
||||
default: "et",
|
||||
languages: ["et"],
|
||||
},
|
||||
EG: {
|
||||
default: "ar",
|
||||
languages: ["ar"],
|
||||
},
|
||||
GR: {
|
||||
default: "el",
|
||||
languages: ["el"],
|
||||
},
|
||||
PT: {
|
||||
default: "pt",
|
||||
languages: ["pt"],
|
||||
},
|
||||
BR: {
|
||||
default: "pt",
|
||||
languages: ["pt"],
|
||||
},
|
||||
RS: {
|
||||
default: "sr",
|
||||
languages: ["sr"],
|
||||
},
|
||||
SA: {
|
||||
default: "ar",
|
||||
languages: ["ar"],
|
||||
},
|
||||
MA: {
|
||||
default: "ar",
|
||||
languages: ["ar", "fr"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@popcorntime/i18n",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types.ts"
|
||||
},
|
||||
"scripts": {},
|
||||
"devDependencies": {
|
||||
"@popcorntime/typescript-config": "workspace:*",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {}
|
||||
"name": "@popcorntime/i18n",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types.ts"
|
||||
},
|
||||
"scripts": {},
|
||||
"devDependencies": {
|
||||
"@popcorntime/typescript-config": "workspace:*",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,43 @@
|
||||
import {
|
||||
i18n,
|
||||
Locale,
|
||||
Country,
|
||||
countryVariations,
|
||||
DefaultLocaleTag,
|
||||
LocaleTag,
|
||||
CountryPath,
|
||||
countries,
|
||||
type Country,
|
||||
type CountryPath,
|
||||
countries,
|
||||
countryVariations,
|
||||
type DefaultLocaleTag,
|
||||
i18n,
|
||||
type Locale,
|
||||
type LocaleTag,
|
||||
} from "./types.js";
|
||||
|
||||
export { i18n, locales } from "./types.js";
|
||||
export type { Locale, Country, DefaultLocaleTag, LocaleTag, CountryPath };
|
||||
export { countries };
|
||||
export type LocaleCookie = {
|
||||
locale: Locale;
|
||||
forced: boolean;
|
||||
locale: Locale;
|
||||
forced: boolean;
|
||||
};
|
||||
|
||||
export function getCountryLocale(country: Country): Locale {
|
||||
return i18n.raw[country].default;
|
||||
return i18n.raw[country].default;
|
||||
}
|
||||
|
||||
export function isDefaultLocale(country: Country, userLocale: Locale) {
|
||||
return getCountryLocale(country) === userLocale;
|
||||
return getCountryLocale(country) === userLocale;
|
||||
}
|
||||
|
||||
export function getLocalesForCountry(country: Country) {
|
||||
return i18n.raw[country].languages;
|
||||
return i18n.raw[country].languages;
|
||||
}
|
||||
|
||||
export function getVariationForLocale(country: Country, userLocale: Locale) {
|
||||
if (isDefaultLocale(country, userLocale)) {
|
||||
if (countryVariations[country]) {
|
||||
return countryVariations[country];
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
// return default variation
|
||||
return 1;
|
||||
}
|
||||
if (isDefaultLocale(country, userLocale)) {
|
||||
if (countryVariations[country]) {
|
||||
return countryVariations[country];
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
// return default variation
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,82 +4,80 @@ export type Country = keyof typeof rawLocales;
|
||||
export type Locale = RawLocales["languages"][number];
|
||||
export type Variation = 1 | 2 | 3 | 4 | 5;
|
||||
export type LocaleTag = {
|
||||
[C in Country]: `${(typeof rawLocales)[C]["languages"][number]}-${Uppercase<C>}`;
|
||||
[C in Country]: `${(typeof rawLocales)[C]["languages"][number]}-${Uppercase<C>}`;
|
||||
}[Country];
|
||||
export type DefaultLocaleTag = {
|
||||
[C in Country]: `${(typeof rawLocales)[C]["default"]}-${Uppercase<C>}`;
|
||||
[C in Country]: `${(typeof rawLocales)[C]["default"]}-${Uppercase<C>}`;
|
||||
}[Country];
|
||||
export type CountryPath = {
|
||||
[C in Country]:
|
||||
| C
|
||||
| Exclude<
|
||||
`${C}-${(typeof rawLocales)[C]["languages"][number]}`,
|
||||
`${C}-${(typeof rawLocales)[C]["default"]}`
|
||||
>;
|
||||
[C in Country]:
|
||||
| C
|
||||
| Exclude<
|
||||
`${C}-${(typeof rawLocales)[C]["languages"][number]}`,
|
||||
`${C}-${(typeof rawLocales)[C]["default"]}`
|
||||
>;
|
||||
}[Country];
|
||||
|
||||
function buildCountryVariations() {
|
||||
const result: Partial<Record<Country, Variation>> = {};
|
||||
const localeVariation: Partial<Record<Locale, Variation>> = {};
|
||||
const result: Partial<Record<Country, Variation>> = {};
|
||||
const localeVariation: Partial<Record<Locale, Variation>> = {};
|
||||
|
||||
for (const country of Object.keys(rawLocales) as Country[]) {
|
||||
const defaultLocale = rawLocales[country].default;
|
||||
const order = localeVariation[defaultLocale] || 1;
|
||||
for (const country of Object.keys(rawLocales) as Country[]) {
|
||||
const defaultLocale = rawLocales[country].default;
|
||||
const order = localeVariation[defaultLocale] || 1;
|
||||
|
||||
result[country] = order as Variation;
|
||||
result[country] = order as Variation;
|
||||
|
||||
if (order < 5) {
|
||||
localeVariation[defaultLocale] = (order + 1) as Variation;
|
||||
} else {
|
||||
localeVariation[defaultLocale] = 1;
|
||||
}
|
||||
}
|
||||
if (order < 5) {
|
||||
localeVariation[defaultLocale] = (order + 1) as Variation;
|
||||
} else {
|
||||
localeVariation[defaultLocale] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result as Record<Country, Variation>;
|
||||
return result as Record<Country, Variation>;
|
||||
}
|
||||
|
||||
export const countryVariations = buildCountryVariations();
|
||||
|
||||
export const locales = Array.from(
|
||||
new Set(Object.values(rawLocales).flatMap((data) => data.languages))
|
||||
new Set(Object.values(rawLocales).flatMap(data => data.languages))
|
||||
) as Locale[];
|
||||
|
||||
export const localeTags = Array.from(
|
||||
new Set(
|
||||
Object.entries(rawLocales).flatMap(([country, data]) =>
|
||||
data.languages.map((lang) => `${lang}-${country.toUpperCase()}`)
|
||||
)
|
||||
)
|
||||
new Set(
|
||||
Object.entries(rawLocales).flatMap(([country, data]) =>
|
||||
data.languages.map(lang => `${lang}-${country.toUpperCase()}`)
|
||||
)
|
||||
)
|
||||
) as LocaleTag[];
|
||||
|
||||
export const defaultLocaleTags = Array.from(
|
||||
new Set(
|
||||
Object.entries(rawLocales).flatMap(
|
||||
([country, data]) => `${data.default}-${country.toUpperCase()}`
|
||||
)
|
||||
)
|
||||
new Set(
|
||||
Object.entries(rawLocales).flatMap(
|
||||
([country, data]) => `${data.default}-${country.toUpperCase()}`
|
||||
)
|
||||
)
|
||||
) as DefaultLocaleTag[];
|
||||
|
||||
export const countryPaths = Array.from(
|
||||
new Set(
|
||||
Object.entries(rawLocales).flatMap(([country, data]) => [
|
||||
country,
|
||||
...data.languages
|
||||
.filter((lang) => lang !== data.default)
|
||||
.map((lang) => `${country}-${lang}`),
|
||||
])
|
||||
)
|
||||
new Set(
|
||||
Object.entries(rawLocales).flatMap(([country, data]) => [
|
||||
country,
|
||||
...data.languages.filter(lang => lang !== data.default).map(lang => `${country}-${lang}`),
|
||||
])
|
||||
)
|
||||
) as CountryPath[];
|
||||
|
||||
export const countries = Object.keys(rawLocales) as Country[];
|
||||
|
||||
export const i18n = {
|
||||
defaultLocale: "en",
|
||||
defaultCountry: "US",
|
||||
locales,
|
||||
countries,
|
||||
raw: rawLocales,
|
||||
localeTags,
|
||||
defaultLocaleTags,
|
||||
countryPaths,
|
||||
defaultLocale: "en",
|
||||
defaultCountry: "US",
|
||||
locales,
|
||||
countries,
|
||||
raw: rawLocales,
|
||||
localeTags,
|
||||
defaultLocaleTags,
|
||||
countryPaths,
|
||||
} as const;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"extends": "@popcorntime/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@popcorntime/i18n/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"extends": "@popcorntime/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@popcorntime/i18n/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
25
packages/popcorntime-ui/.storybook/main.ts
Normal file
25
packages/popcorntime-ui/.storybook/main.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
function getAbsolutePath(value: string) {
|
||||
return dirname(require.resolve(join(value, "package.json")));
|
||||
}
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-themes"),
|
||||
getAbsolutePath("@storybook/addon-a11y"),
|
||||
getAbsolutePath("@storybook/addon-docs"),
|
||||
],
|
||||
|
||||
framework: {
|
||||
name: getAbsolutePath("@storybook/react-vite"),
|
||||
options: {},
|
||||
},
|
||||
|
||||
typescript: {
|
||||
check: true,
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
33
packages/popcorntime-ui/.storybook/preview.tsx
Normal file
33
packages/popcorntime-ui/.storybook/preview.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { withThemeByDataAttribute } from "@storybook/addon-themes";
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import "../src/styles/globals.css";
|
||||
|
||||
export const decorators = [
|
||||
withThemeByDataAttribute({
|
||||
themes: {
|
||||
light: "light",
|
||||
dark: "dark",
|
||||
},
|
||||
defaultTheme: "light",
|
||||
attributeName: "data-theme",
|
||||
}),
|
||||
];
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
docs: {
|
||||
story: { inline: false },
|
||||
inlineStories: false,
|
||||
},
|
||||
a11y: {
|
||||
context: "#storybook-root",
|
||||
config: {},
|
||||
options: {},
|
||||
manual: false,
|
||||
},
|
||||
},
|
||||
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
35
packages/popcorntime-ui/STORYBOOK.md
Normal file
35
packages/popcorntime-ui/STORYBOOK.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Storybook Documentation
|
||||
|
||||
This project uses [Storybook](https://storybook.js.org/) to document, develop, and test the UI components in `@popcorntime/ui`.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Local Development
|
||||
|
||||
To run Storybook locally:
|
||||
|
||||
```bash
|
||||
# From the project root
|
||||
pnpm --filter @popcorntime/ui storybook
|
||||
|
||||
# Or from the UI package directory
|
||||
cd packages/popcorntime-ui
|
||||
pnpm storybook
|
||||
```
|
||||
|
||||
This will start Storybook on `http://localhost:6006`.
|
||||
|
||||
### Building for Production
|
||||
|
||||
To build a static version of Storybook:
|
||||
|
||||
```bash
|
||||
# From the project root
|
||||
pnpm --filter @popcorntime/ui build-storybook
|
||||
|
||||
# Or from the UI package directory
|
||||
cd packages/popcorntime-ui
|
||||
pnpm build-storybook
|
||||
```
|
||||
|
||||
The static build will be created in `packages/popcorntime-ui/storybook-static/`.
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@popcorntime/ui/components",
|
||||
"utils": "@popcorntime/ui/lib/utils",
|
||||
"hooks": "@popcorntime/ui/hooks",
|
||||
"lib": "@popcorntime/ui/lib",
|
||||
"ui": "@popcorntime/ui/components"
|
||||
}
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@popcorntime/ui/components",
|
||||
"utils": "@popcorntime/ui/lib/utils",
|
||||
"hooks": "@popcorntime/ui/hooks",
|
||||
"lib": "@popcorntime/ui/lib",
|
||||
"ui": "@popcorntime/ui/components"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,73 @@
|
||||
{
|
||||
"name": "@popcorntime/ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./styles.css": "./src/styles/globals.css",
|
||||
"./postcss.config": "./postcss.config.mjs",
|
||||
"./lib/*": "./src/lib/*.ts",
|
||||
"./blocks/*": "./src/blocks/*.tsx",
|
||||
"./components/*": "./src/components/*.tsx",
|
||||
"./hooks/*": "./src/hooks/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@popcorntime/typescript-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react-swc": "4.1.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"globals": "^16.4.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^6.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"sonner": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"framer-motion": "^12.23.21",
|
||||
"next-themes": "^0.4.6"
|
||||
}
|
||||
"name": "@popcorntime/ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./styles.css": "./src/styles/globals.css",
|
||||
"./postcss.config": "./postcss.config.mjs",
|
||||
"./lib/*": "./src/lib/*.ts",
|
||||
"./blocks/*": "./src/blocks/*.tsx",
|
||||
"./components/*": "./src/components/*.tsx",
|
||||
"./hooks/*": "./src/hooks/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"chromatic": "chromatic --exit-zero-on-changes",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@popcorntime/typescript-config": "workspace:*",
|
||||
"@storybook/addon-a11y": "^9.1.8",
|
||||
"@storybook/addon-docs": "^9.1.8",
|
||||
"@storybook/addon-themes": "^9.1.8",
|
||||
"@storybook/react-vite": "^9.1.8",
|
||||
"@storybook/test-runner": "^0.23.0",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react-swc": "4.1.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"chromatic": "^13.2.1",
|
||||
"globals": "^16.4.0",
|
||||
"postcss": "^8.5.3",
|
||||
"storybook": "^9.1.8",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^6.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "catalog:",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"framer-motion": "^12.23.21",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"sonner": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: { "@tailwindcss/postcss": {} },
|
||||
plugins: { "@tailwindcss/postcss": {} },
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
150
packages/popcorntime-ui/src/Welcome.stories.tsx
Normal file
150
packages/popcorntime-ui/src/Welcome.stories.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
const meta = {
|
||||
title: "Welcome",
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
page: () => (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">@popcorntime/ui</h1>
|
||||
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<p className="text-lg text-muted-foreground mb-8">
|
||||
Welcome to the Popcorn Time UI component library documentation.
|
||||
This Storybook contains interactive examples and documentation for
|
||||
all our reusable UI components.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
📚 What you'll find here
|
||||
</h2>
|
||||
<ul className="space-y-2 mb-8">
|
||||
<li>
|
||||
<strong>Component Examples</strong> - Interactive stories
|
||||
showing components in different states
|
||||
</li>
|
||||
<li>
|
||||
<strong>Documentation</strong> - Comprehensive guides and prop
|
||||
tables
|
||||
</li>
|
||||
<li>
|
||||
<strong>Accessibility Testing</strong> - Built-in a11y checks
|
||||
for inclusive design
|
||||
</li>
|
||||
<li>
|
||||
<strong>Design Guidelines</strong> - Best practices and usage
|
||||
patterns
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
🎨 Component Coverage
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Form Components</h3>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>
|
||||
Button - Primary, secondary, and specialized actions
|
||||
</li>
|
||||
<li>Input - Text fields with validation states</li>
|
||||
<li>Checkbox - Selection controls</li>
|
||||
<li>Label - Form element labels</li>
|
||||
<li>Toggle - Switch controls</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Display Components</h3>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>Avatar - User profile images</li>
|
||||
<li>Badge - Status and category indicators</li>
|
||||
<li>Separator - Visual content dividers</li>
|
||||
<li>Tooltip - Contextual help and information</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Layout Components</h3>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>Dialog - Modal and overlay content</li>
|
||||
<li>Tabs - Content organization</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-4">🚀 Getting Started</h2>
|
||||
<div className="bg-muted p-4 rounded-lg mb-8">
|
||||
<p className="text-sm mb-2">
|
||||
To use these components in your project:
|
||||
</p>
|
||||
<pre className="bg-background p-3 rounded text-xs overflow-x-auto">
|
||||
<code>{`import { Button, Input } from '@popcorntime/ui/components/button'
|
||||
import { Dialog, DialogContent } from '@popcorntime/ui/components/dialog'
|
||||
|
||||
function MyComponent() {
|
||||
return (
|
||||
<div>
|
||||
<Button variant="primary">Click me</Button>
|
||||
<Input placeholder="Enter text..." />
|
||||
</div>
|
||||
)
|
||||
}`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-4">✨ Features</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="text-2xl mb-2">🎛️</div>
|
||||
<h3 className="font-medium mb-1">Interactive Controls</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Modify props in real-time
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="text-2xl mb-2">♿</div>
|
||||
<h3 className="font-medium mb-1">Accessibility</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Built-in a11y testing
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="text-2xl mb-2">📱</div>
|
||||
<h3 className="font-medium mb-1">Responsive</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Mobile-first design
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Welcome: Story = {
|
||||
render: () => (
|
||||
<div className="text-center p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Welcome to PopcornTime UI</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Explore our component library using the sidebar navigation
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary text-primary-foreground">
|
||||
11 Components
|
||||
</span>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-secondary text-secondary-foreground">
|
||||
Fully Accessible
|
||||
</span>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-accent text-accent-foreground">
|
||||
TypeScript
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
336
packages/popcorntime-ui/src/components/alert-dialog.stories.tsx
Normal file
336
packages/popcorntime-ui/src/components/alert-dialog.stories.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@popcorntime/ui/components/alert-dialog";
|
||||
import { Button } from "@popcorntime/ui/components/button.js";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
const meta = {
|
||||
title: "Components/AlertDialog",
|
||||
component: AlertDialog,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A modal dialog that interrupts the user with important content and expects a response.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
open: {
|
||||
control: "boolean",
|
||||
description: "Whether the alert dialog is open",
|
||||
},
|
||||
onOpenChange: {
|
||||
action: "onOpenChange",
|
||||
description: "Callback when dialog open state changes",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onOpenChange: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof AlertDialog>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline">Show Dialog</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete your account and remove your
|
||||
data from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction>Continue</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
};
|
||||
|
||||
export const Destructive: Story = {
|
||||
render: () => (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">Delete Account</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Account</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete your PopcornTime account and all associated data. You will
|
||||
lose access to your watchlist, favorites, and viewing history.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep Account</AlertDialogCancel>
|
||||
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete Account
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Alert dialog for destructive actions with appropriate styling",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const RemoveFromWatchlist: Story = {
|
||||
render: () => (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
Remove from Watchlist
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove from Watchlist?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"Inception" will be removed from your watchlist. You can add it back anytime from the
|
||||
movie details page.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep in Watchlist</AlertDialogCancel>
|
||||
<AlertDialogAction>Remove</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Alert dialog for removing items from watchlist",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ClearDownloads: Story = {
|
||||
render: () => (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline">Clear All Downloads</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear All Downloads?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete all downloaded movies and TV show episodes from your device. This will
|
||||
free up storage space but you'll need to download them again to watch offline.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Clear Downloads
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Alert dialog for clearing downloaded content",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SignOut: Story = {
|
||||
render: () => (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost">Sign Out</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Sign out of PopcornTime?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You'll need to sign in again to access your personalized content, watchlist, and
|
||||
preferences.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Stay Signed In</AlertDialogCancel>
|
||||
<AlertDialogAction>Sign Out</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Alert dialog for signing out of the application",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const UnsavedChanges: Story = {
|
||||
render: () => (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline">Close Settings</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have unsaved changes to your preferences. If you close now, your changes will be
|
||||
lost.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Continue Editing</AlertDialogCancel>
|
||||
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Discard Changes
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Alert dialog warning about unsaved changes",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NetworkError: Story = {
|
||||
render: () => (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline">Simulate Network Error</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Connection Failed</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Unable to connect to PopcornTime servers. Please check your internet connection and try
|
||||
again. If the problem persists, the service may be temporarily unavailable.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Go Offline</AlertDialogCancel>
|
||||
<AlertDialogAction>Retry Connection</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Alert dialog for network connectivity issues",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleAction: Story = {
|
||||
render: () => (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline">Show Info</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Update Available</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
PopcornTime 2.1.0 is now available! This update includes performance improvements, bug
|
||||
fixes, and new features. The update will be installed automatically when you restart the
|
||||
application.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction className="w-full">Got it</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Alert dialog with single action button for informational messages",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
render: () => (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button>Custom Styled Dialog</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="max-w-lg">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-2xl flex items-center gap-2">
|
||||
🎬 Premium Features
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-base">
|
||||
Upgrade to PopcornTime Premium to unlock exclusive features like 4K streaming, ad-free
|
||||
experience, offline downloads, and access to premium content libraries.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
<span>4K Ultra HD Streaming</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
<span>No Advertisements</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
<span>Unlimited Downloads</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Maybe Later</AlertDialogCancel>
|
||||
<AlertDialogAction className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700">
|
||||
Upgrade Now
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Alert dialog with custom styling and content layout",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,157 +1,134 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
import { buttonVariants } from "@popcorntime/ui/components/button";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@popcorntime/ui/lib/utils"
|
||||
import { buttonVariants } from "@popcorntime/ui/components/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
|
||||
204
packages/popcorntime-ui/src/components/avatar.stories.tsx
Normal file
204
packages/popcorntime-ui/src/components/avatar.stories.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@popcorntime/ui/components/avatar";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Avatar",
|
||||
component: Avatar,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"An avatar component for displaying user profile pictures with fallback support.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Avatar>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Avatar>
|
||||
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
|
||||
<AvatarFallback>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithFallback: Story = {
|
||||
render: () => (
|
||||
<Avatar>
|
||||
<AvatarImage src="/broken-image.jpg" alt="@user" />
|
||||
<AvatarFallback>JD</AvatarFallback>
|
||||
</Avatar>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Avatar with a broken image URL, showing the fallback initials",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
|
||||
<AvatarFallback className="text-xs">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
|
||||
<AvatarFallback className="text-sm">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
|
||||
<AvatarFallback>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar className="size-12">
|
||||
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
|
||||
<AvatarFallback>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar className="size-16">
|
||||
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
|
||||
<AvatarFallback className="text-lg">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Avatars in different sizes",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FallbackVariations: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar>
|
||||
<AvatarFallback>JD</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar>
|
||||
<AvatarFallback className="bg-primary text-primary-foreground">AB</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar>
|
||||
<AvatarFallback className="bg-destructive text-destructive-foreground">?</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar>
|
||||
<AvatarFallback>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Different fallback styles including colored backgrounds and icons",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const UserList: Story = {
|
||||
render: () => {
|
||||
const users = [
|
||||
{
|
||||
name: "John Doe",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=32&h=32&fit=crop&crop=face",
|
||||
initials: "JD",
|
||||
},
|
||||
{
|
||||
name: "Jane Smith",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1494790108755-2616b86b5e0b?w=32&h=32&fit=crop&crop=face",
|
||||
initials: "JS",
|
||||
},
|
||||
{ name: "Alice Johnson", image: "/broken.jpg", initials: "AJ" },
|
||||
{
|
||||
name: "Bob Wilson",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=32&h=32&fit=crop&crop=face",
|
||||
initials: "BW",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{users.map(user => (
|
||||
<div key={user.name} className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage src={user.image} alt={`@${user.name}`} />
|
||||
<AvatarFallback>{user.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-medium">{user.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Avatar component used in a user list with mixed image loading states",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AvatarGroup: Story = {
|
||||
render: () => (
|
||||
<div className="flex -space-x-2">
|
||||
<Avatar className="border-2 border-background">
|
||||
<AvatarImage
|
||||
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=32&h=32&fit=crop&crop=face"
|
||||
alt="User 1"
|
||||
/>
|
||||
<AvatarFallback>U1</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar className="border-2 border-background">
|
||||
<AvatarImage
|
||||
src="https://images.unsplash.com/photo-1494790108755-2616b86b5e0b?w=32&h=32&fit=crop&crop=face"
|
||||
alt="User 2"
|
||||
/>
|
||||
<AvatarFallback>U2</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar className="border-2 border-background">
|
||||
<AvatarImage
|
||||
src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=32&h=32&fit=crop&crop=face"
|
||||
alt="User 3"
|
||||
/>
|
||||
<AvatarFallback>U3</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar className="border-2 border-background">
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-xs">+5</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Overlapping avatar group showing multiple users with a count indicator",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,53 +1,40 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@popcorntime/ui/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
|
||||
173
packages/popcorntime-ui/src/components/badge.stories.tsx
Normal file
173
packages/popcorntime-ui/src/components/badge.stories.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Badge } from "@popcorntime/ui/components/badge.js";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Badge",
|
||||
component: Badge,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component: "A small label component for displaying status, categories, or other metadata.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["default", "secondary", "destructive", "outline"],
|
||||
description: "The visual variant of the badge",
|
||||
},
|
||||
children: {
|
||||
control: "text",
|
||||
description: "Badge content",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Badge>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: "Badge",
|
||||
},
|
||||
};
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<Badge variant="default">Default</Badge>
|
||||
<Badge variant="secondary">Secondary</Badge>
|
||||
<Badge variant="destructive">Destructive</Badge>
|
||||
<Badge variant="outline">Outline</Badge>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "All available badge variants",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Status: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<Badge variant="default">Active</Badge>
|
||||
<Badge variant="secondary">Pending</Badge>
|
||||
<Badge variant="destructive">Error</Badge>
|
||||
<Badge variant="outline">Draft</Badge>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Badges used to indicate different statuses",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<Badge variant="default" className="gap-1">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Completed
|
||||
</Badge>
|
||||
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Failed
|
||||
</Badge>
|
||||
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Processing
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Badges with icons to provide visual context",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Numbers: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<Badge variant="default">1</Badge>
|
||||
<Badge variant="default">99+</Badge>
|
||||
<Badge variant="secondary">42</Badge>
|
||||
<Badge variant="outline">0</Badge>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Badges displaying numbers or counts",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Categories: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-2 items-center max-w-md">
|
||||
<Badge variant="outline">React</Badge>
|
||||
<Badge variant="outline">TypeScript</Badge>
|
||||
<Badge variant="outline">Tailwind CSS</Badge>
|
||||
<Badge variant="outline">Storybook</Badge>
|
||||
<Badge variant="outline">Accessibility</Badge>
|
||||
<Badge variant="outline">Design System</Badge>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Badges used as category tags or labels",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<Badge className="text-xs px-2 py-0.5">Small</Badge>
|
||||
<Badge className="text-sm px-3 py-1">Medium</Badge>
|
||||
<Badge className="text-base px-4 py-1.5">Large</Badge>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Custom badge sizes using className overrides",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,35 +1,32 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,109 +1,101 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@popcorntime/ui/lib/utils"
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="presentation"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
|
||||
98
packages/popcorntime-ui/src/components/button.mdx
Normal file
98
packages/popcorntime-ui/src/components/button.mdx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Meta, Story, Canvas, Controls } from '@storybook/addon-docs/blocks';
|
||||
import * as ButtonStories from './button.stories';
|
||||
|
||||
<Meta of={ButtonStories} />
|
||||
|
||||
# Button
|
||||
|
||||
A versatile button component with multiple variants and sizes, built with Radix UI slot for composition.
|
||||
|
||||
The Button component is one of the most important interactive elements in your UI. It supports various visual styles, sizes, and can be composed with other elements using the `asChild` prop.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple variants**: Choose from default, destructive, outline, secondary, accent, ghost, and link styles
|
||||
- **Flexible sizing**: From small to extra large, including specialized icon button sizes
|
||||
- **Composition ready**: Use `asChild` to render as any element while maintaining button styling
|
||||
- **Accessibility focused**: Proper focus management and keyboard navigation
|
||||
- **Icon support**: Built-in spacing and sizing for icons
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { Button } from '@popcorntime/ui/components/button'
|
||||
|
||||
function Example() {
|
||||
return (
|
||||
<Button variant="default" size="lg">
|
||||
Click me
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Button
|
||||
|
||||
<Canvas of={ButtonStories.Default} />
|
||||
|
||||
### All Variants
|
||||
|
||||
<Canvas of={ButtonStories.Variants} />
|
||||
|
||||
### Different Sizes
|
||||
|
||||
<Canvas of={ButtonStories.Sizes} />
|
||||
|
||||
### Icon Buttons
|
||||
|
||||
<Canvas of={ButtonStories.IconSizes} />
|
||||
|
||||
### With Icons
|
||||
|
||||
<Canvas of={ButtonStories.WithIcon} />
|
||||
|
||||
### Disabled State
|
||||
|
||||
<Canvas of={ButtonStories.Disabled} />
|
||||
|
||||
## Props
|
||||
|
||||
<Controls of={ButtonStories.Default} />
|
||||
|
||||
## Design Guidelines
|
||||
|
||||
### When to use
|
||||
|
||||
- **Primary actions**: Use the default variant for the main action on a page
|
||||
- **Secondary actions**: Use outline or ghost variants for less important actions
|
||||
- **Destructive actions**: Use the destructive variant for delete or removal actions
|
||||
- **Navigation**: Use the link variant for navigation-like actions
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Label clarity**: Use clear, action-oriented labels like "Save", "Delete", or "Continue"
|
||||
2. **Icon consistency**: When using icons, ensure they're consistent in style and size
|
||||
3. **Loading states**: Consider adding loading indicators for async actions
|
||||
4. **Touch targets**: Ensure buttons meet minimum touch target sizes (44px on mobile)
|
||||
|
||||
### Accessibility
|
||||
|
||||
- The button component automatically includes proper ARIA attributes
|
||||
- Use semantic HTML button elements for actions
|
||||
- Provide descriptive labels, especially for icon-only buttons
|
||||
- Ensure sufficient color contrast ratios
|
||||
- Support keyboard navigation (Space and Enter keys)
|
||||
|
||||
## Composition with asChild
|
||||
|
||||
The `asChild` prop allows you to compose the Button with other elements:
|
||||
|
||||
```tsx
|
||||
<Button asChild>
|
||||
<a href="/profile">Go to Profile</a>
|
||||
</Button>
|
||||
```
|
||||
|
||||
This renders an anchor tag with button styling, maintaining semantic meaning while providing visual consistency.
|
||||
176
packages/popcorntime-ui/src/components/button.stories.tsx
Normal file
176
packages/popcorntime-ui/src/components/button.stories.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { Button } from "@popcorntime/ui/components/button";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Button",
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A versatile button component with multiple variants and sizes, built with Radix UI slot for composition.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["default", "destructive", "outline", "secondary", "accent", "ghost", "link"],
|
||||
description: "The visual style variant of the button",
|
||||
},
|
||||
size: {
|
||||
control: { type: "select" },
|
||||
options: ["default", "sm", "lg", "xl", "xxl", "icon", "iconXl", "icon3Xl"],
|
||||
description: "The size variant of the button",
|
||||
},
|
||||
asChild: {
|
||||
control: "boolean",
|
||||
description: "Render as a child element (uses Radix UI Slot)",
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
description: "Whether the button is disabled",
|
||||
},
|
||||
children: {
|
||||
control: "text",
|
||||
description: "Button content",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onClick: fn(),
|
||||
children: "Button",
|
||||
},
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: "Default Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<Button variant="default">Default</Button>
|
||||
<Button variant="destructive">Destructive</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="accent">Accent</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="link">Link</Button>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "All available button variants",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="default">Default</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
<Button size="xl">Extra Large</Button>
|
||||
<Button size="xxl">XXL</Button>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Different button sizes",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const IconSizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<Button size="icon">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button size="iconXl">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button size="icon3Xl">
|
||||
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Icon button sizes with SVG icons",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<Button disabled>Disabled Default</Button>
|
||||
<Button variant="outline" disabled>
|
||||
Disabled Outline
|
||||
</Button>
|
||||
<Button variant="destructive" disabled>
|
||||
Disabled Destructive
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Disabled state for different variants",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<Button>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Item
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
Download
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Buttons with icons using proper spacing",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,60 +1,53 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as React from "react";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
accent: "bg-accent text-accent-foreground shadow-xs hover:bg-accent/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
xl: "h-12 rounded-md px-10 text-md",
|
||||
xxl: "h-14 rounded-md px-12 text-lg",
|
||||
iconXl: "size-12 px-4 py-2 [&_svg]:size-6",
|
||||
icon3Xl: "size-20 px-4 py-2 [&_svg]:size-12",
|
||||
icon: "size-10 [&_svg]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
accent: "bg-accent text-accent-foreground shadow-xs hover:bg-accent/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
xl: "h-12 rounded-md px-10 text-md",
|
||||
xxl: "h-14 rounded-md px-12 text-lg",
|
||||
iconXl: "size-12 px-4 py-2 [&_svg]:size-6",
|
||||
icon3Xl: "size-20 px-4 py-2 [&_svg]:size-12",
|
||||
icon: "size-10 [&_svg]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
|
||||
240
packages/popcorntime-ui/src/components/checkbox.stories.tsx
Normal file
240
packages/popcorntime-ui/src/components/checkbox.stories.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { Checkbox } from "@popcorntime/ui/components/checkbox";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Checkbox",
|
||||
component: Checkbox,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A checkbox component built with Radix UI primitives, featuring proper accessibility and styling.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
checked: {
|
||||
control: "boolean",
|
||||
description: "The checked state of the checkbox",
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
description: "Whether the checkbox is disabled",
|
||||
},
|
||||
required: {
|
||||
control: "boolean",
|
||||
description: "Whether the checkbox is required",
|
||||
},
|
||||
onCheckedChange: {
|
||||
action: "onCheckedChange",
|
||||
description: "Callback when checked state changes",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onCheckedChange: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof Checkbox>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Checked: Story = {
|
||||
args: {
|
||||
checked: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Indeterminate: Story = {
|
||||
args: {
|
||||
checked: "indeterminate",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Checkbox in indeterminate state, useful for parent checkboxes in hierarchical lists",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<div className="flex gap-4 items-center">
|
||||
<Checkbox disabled />
|
||||
<Checkbox disabled checked />
|
||||
<Checkbox disabled checked="indeterminate" />
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Disabled checkboxes in different states",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabel: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="terms" />
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Accept terms and conditions
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="newsletter" checked />
|
||||
<label
|
||||
htmlFor="newsletter"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Subscribe to newsletter
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="disabled" disabled />
|
||||
<label
|
||||
htmlFor="disabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
This option is disabled
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Checkboxes with proper labels using htmlFor and id attributes",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FormGroup: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="text-sm font-medium">Notification Preferences</legend>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="email" checked />
|
||||
<label htmlFor="email" className="text-sm leading-none">
|
||||
Email notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="push" />
|
||||
<label htmlFor="push" className="text-sm leading-none">
|
||||
Push notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="sms" />
|
||||
<label htmlFor="sms" className="text-sm leading-none">
|
||||
SMS notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="phone" disabled />
|
||||
<label htmlFor="phone" className="text-sm leading-none peer-disabled:opacity-70">
|
||||
Phone calls (coming soon)
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Group of related checkboxes with fieldset and legend for semantic structure",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Validation: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="valid" checked />
|
||||
<label htmlFor="valid" className="text-sm leading-none">
|
||||
Valid selection
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="invalid" aria-invalid={true} />
|
||||
<label htmlFor="invalid" className="text-sm leading-none">
|
||||
Invalid selection
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-destructive">This field is required</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Checkbox validation states using aria-invalid attribute",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ListSelection: Story = {
|
||||
render: () => {
|
||||
const items = [
|
||||
{ id: "all", label: "Select All", checked: "indeterminate" as const },
|
||||
{ id: "item1", label: "First Item", checked: true },
|
||||
{ id: "item2", label: "Second Item", checked: true },
|
||||
{ id: "item3", label: "Third Item", checked: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-center space-x-2 ${index === 0 ? "border-b pb-2 mb-2" : ""}`}
|
||||
>
|
||||
<Checkbox id={item.id} checked={item.checked} />
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className={`text-sm leading-none ${index === 0 ? "font-medium" : ""}`}
|
||||
>
|
||||
{item.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Hierarchical checkbox list with parent checkbox in indeterminate state",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,32 +1,28 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@popcorntime/ui/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
export { Checkbox };
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
||||
384
packages/popcorntime-ui/src/components/command.stories.tsx
Normal file
384
packages/popcorntime-ui/src/components/command.stories.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Calendar, CreditCard, Film, Search, Settings, Smile, Star, Tv, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
import { Button } from "@popcorntime/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@popcorntime/ui/components/command";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Command",
|
||||
component: Command,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Command palette component for fast navigation and action execution, built with cmdk.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
onValueChange: {
|
||||
action: "onValueChange",
|
||||
description: "Callback when selected value changes",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onValueChange: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof Command>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<Command className="rounded-lg border shadow-md">
|
||||
<CommandInput placeholder="Type a command or search..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading="Suggestions">
|
||||
<CommandItem>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
<span>Calendar</span>
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<Smile className="mr-2 h-4 w-4" />
|
||||
<span>Search Emoji</span>
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
<span>Calculator</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Settings">
|
||||
<CommandItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
<CommandShortcut>⌘P</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
<span>Billing</span>
|
||||
<CommandShortcut>⌘B</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
<CommandShortcut>⌘S</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Dialog: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Press the button to open the command dialog or use ⌘K
|
||||
</p>
|
||||
<Button onClick={() => setOpen(true)} variant="outline">
|
||||
Open Command Palette
|
||||
</Button>
|
||||
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Command Center"
|
||||
description="Search for media, navigate, or execute commands"
|
||||
>
|
||||
<CommandInput placeholder="Search for movies, shows, or commands..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading="Media">
|
||||
<CommandItem>
|
||||
<Film className="mr-2 h-4 w-4" />
|
||||
<span>Browse Movies</span>
|
||||
<CommandShortcut>⌘M</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<Tv className="mr-2 h-4 w-4" />
|
||||
<span>Browse TV Shows</span>
|
||||
<CommandShortcut>⌘T</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<Star className="mr-2 h-4 w-4" />
|
||||
<span>My Favorites</span>
|
||||
<CommandShortcut>⌘F</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Navigation">
|
||||
<CommandItem>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
<span>Global Search</span>
|
||||
<CommandShortcut>/</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Preferences</span>
|
||||
<CommandShortcut>⌘,</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Account">
|
||||
<CommandItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
<span>Subscription</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Command dialog overlay for quick navigation and actions, similar to VS Code or Raycast",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const PopcornTimeCommands: Story = {
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<Command className="rounded-lg border shadow-md">
|
||||
<CommandInput placeholder="Search movies, shows, or commands..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="text-center py-6">
|
||||
<Search className="mx-auto h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No results found</p>
|
||||
<p className="text-xs text-muted-foreground">Try searching for a movie or TV show</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Quick Actions">
|
||||
<CommandItem value="search-movies">
|
||||
<Film className="mr-2 h-4 w-4" />
|
||||
<span>Search Movies</span>
|
||||
<CommandShortcut>⌘M</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem value="search-shows">
|
||||
<Tv className="mr-2 h-4 w-4" />
|
||||
<span>Search TV Shows</span>
|
||||
<CommandShortcut>⌘T</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem value="favorites">
|
||||
<Star className="mr-2 h-4 w-4" />
|
||||
<span>View Favorites</span>
|
||||
<CommandShortcut>⌘F</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Popular Movies">
|
||||
<CommandItem value="inception">
|
||||
<Film className="mr-2 h-4 w-4" />
|
||||
<div className="flex flex-col items-start">
|
||||
<span>Inception</span>
|
||||
<span className="text-xs text-muted-foreground">2010 • Sci-Fi, Thriller</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
<CommandItem value="interstellar">
|
||||
<Film className="mr-2 h-4 w-4" />
|
||||
<div className="flex flex-col items-start">
|
||||
<span>Interstellar</span>
|
||||
<span className="text-xs text-muted-foreground">2014 • Sci-Fi, Drama</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
<CommandItem value="the-dark-knight">
|
||||
<Film className="mr-2 h-4 w-4" />
|
||||
<div className="flex flex-col items-start">
|
||||
<span>The Dark Knight</span>
|
||||
<span className="text-xs text-muted-foreground">2008 • Action, Drama</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Settings">
|
||||
<CommandItem value="preferences">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Preferences</span>
|
||||
<CommandShortcut>⌘,</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem value="account">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Account Settings</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Command palette styled for PopcornTime with media search and app-specific actions",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomEmpty: Story = {
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<Command className="rounded-lg border shadow-md">
|
||||
<CommandInput placeholder="Search..." defaultValue="xyz123notfound" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground/30" />
|
||||
<h3 className="mt-4 text-lg font-semibold">No results found</h3>
|
||||
<p className="mb-4 mt-2 text-sm text-muted-foreground max-w-xs">
|
||||
We couldn't find any movies, shows, or commands matching your search.
|
||||
</p>
|
||||
<Button variant="outline" size="sm">
|
||||
Clear Search
|
||||
</Button>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup heading="Suggestions">
|
||||
<CommandItem>
|
||||
<Film className="mr-2 h-4 w-4" />
|
||||
<span>Browse Popular Movies</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Command component with custom empty state design",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<Command className="rounded-lg border shadow-md">
|
||||
<CommandInput placeholder="Searching..." />
|
||||
<CommandList>
|
||||
<CommandGroup heading="Results">
|
||||
<CommandItem disabled>
|
||||
<div className="flex items-center space-x-2 w-full">
|
||||
<div className="h-4 w-4 rounded bg-muted animate-pulse" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-4 bg-muted rounded animate-pulse" />
|
||||
<div className="h-3 bg-muted rounded animate-pulse w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
<CommandItem disabled>
|
||||
<div className="flex items-center space-x-2 w-full">
|
||||
<div className="h-4 w-4 rounded bg-muted animate-pulse" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-4 bg-muted rounded animate-pulse" />
|
||||
<div className="h-3 bg-muted rounded animate-pulse w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
<CommandItem disabled>
|
||||
<div className="flex items-center space-x-2 w-full">
|
||||
<div className="h-4 w-4 rounded bg-muted animate-pulse" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-4 bg-muted rounded animate-pulse" />
|
||||
<div className="h-3 bg-muted rounded animate-pulse w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Command component showing loading state with skeleton placeholders",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Standalone: Story = {
|
||||
render: () => (
|
||||
<Command className="space-y-6 max-w-md">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Command Input</h3>
|
||||
<CommandInput placeholder="Search anything..." />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Command Items</h3>
|
||||
<div className="rounded-lg border p-2">
|
||||
<CommandItem>
|
||||
<Film className="mr-2 h-4 w-4" />
|
||||
<span>Movies</span>
|
||||
</CommandItem>
|
||||
<CommandItem>
|
||||
<Tv className="mr-2 h-4 w-4" />
|
||||
<span>TV Shows</span>
|
||||
<CommandShortcut>⌘T</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem disabled>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings (disabled)</span>
|
||||
</CommandItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Command Separators</h3>
|
||||
<div className="rounded-lg border p-2">
|
||||
<CommandItem>Item 1</CommandItem>
|
||||
<CommandSeparator />
|
||||
<CommandItem>Item 2</CommandItem>
|
||||
<CommandSeparator />
|
||||
<CommandItem>Item 3</CommandItem>
|
||||
</div>
|
||||
</div>
|
||||
</Command>
|
||||
),
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
story: "Individual command components for testing and customization",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,180 +1,157 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@popcorntime/ui/components/dialog";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@popcorntime/ui/components/dialog";
|
||||
import type * as React from "react";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
onKeyDown,
|
||||
children,
|
||||
...props
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
onKeyDown,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement> | undefined;
|
||||
title?: string;
|
||||
description?: string;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement> | undefined;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
onKeyDown={onKeyDown}
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
onKeyDown={onKeyDown}
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
|
||||
209
packages/popcorntime-ui/src/components/dialog.stories.tsx
Normal file
209
packages/popcorntime-ui/src/components/dialog.stories.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { Button } from "@popcorntime/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@popcorntime/ui/components/dialog";
|
||||
import { Input } from "@popcorntime/ui/components/input";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Dialog",
|
||||
component: Dialog,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A modal dialog component built with Radix UI Dialog primitives. Supports overlay, header, footer, and proper focus management.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
open: {
|
||||
control: "boolean",
|
||||
description: "Whether the dialog is open",
|
||||
},
|
||||
onOpenChange: {
|
||||
action: "onOpenChange",
|
||||
description: "Callback when dialog open state changes",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onOpenChange: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof Dialog>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Open Dialog</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dialog Title</DialogTitle>
|
||||
<DialogDescription>
|
||||
This is a description of what the dialog is for. It provides context about the action
|
||||
that will be performed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is the main content area of the dialog.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button>Continue</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithForm: Story = {
|
||||
render: () => (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Create Account</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Account</DialogTitle>
|
||||
<DialogDescription>Fill out the form below to create your new account.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-sm font-medium">
|
||||
Full Name
|
||||
</label>
|
||||
<Input id="name" placeholder="Enter your full name" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<Input id="email" type="email" placeholder="Enter your email" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<Input id="password" type="password" placeholder="Enter your password" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">Create Account</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Dialog with a form containing multiple input fields",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Confirmation: Story = {
|
||||
render: () => (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive">Delete Item</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the item from our servers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Confirmation dialog with destructive action styling",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: () => (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">View Terms</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Terms of Service</DialogTitle>
|
||||
<DialogDescription>Please read our terms of service carefully.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-y-auto py-4 text-sm">
|
||||
<h3 className="font-semibold mb-2">1. Introduction</h3>
|
||||
<p className="mb-4 text-muted-foreground">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
|
||||
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||
</p>
|
||||
<h3 className="font-semibold mb-2">2. User Responsibilities</h3>
|
||||
<p className="mb-4 text-muted-foreground">
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
|
||||
nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui
|
||||
officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
<h3 className="font-semibold mb-2">3. Privacy Policy</h3>
|
||||
<p className="mb-4 text-muted-foreground">
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque
|
||||
laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi
|
||||
architecto beatae vitae dicta sunt explicabo.
|
||||
</p>
|
||||
<h3 className="font-semibold mb-2">4. Limitations</h3>
|
||||
<p className="mb-4 text-muted-foreground">
|
||||
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia
|
||||
consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Decline</Button>
|
||||
</DialogClose>
|
||||
<Button>Accept</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Dialog with scrollable content for long text",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,127 +1,113 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import type * as React from "react";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
372
packages/popcorntime-ui/src/components/dropdown-menu.stories.tsx
Normal file
372
packages/popcorntime-ui/src/components/dropdown-menu.stories.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
Download,
|
||||
Edit,
|
||||
LogOut,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
Share,
|
||||
Star,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@popcorntime/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@popcorntime/ui/components/dropdown-menu";
|
||||
|
||||
const meta = {
|
||||
title: "Components/DropdownMenu",
|
||||
component: DropdownMenu,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component: "A dropdown menu component with various actions and groups.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof DropdownMenu>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">Open Menu</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
<DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
};
|
||||
|
||||
export const MovieActions: Story = {
|
||||
render: () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48">
|
||||
<DropdownMenuLabel>Movie Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<span>Download</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Star className="mr-2 h-4 w-4" />
|
||||
<span>Add to Favorites</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Share className="mr-2 h-4 w-4" />
|
||||
<span>Share</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<span>Edit Info</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>Remove from Library</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Dropdown menu for movie item actions in PopcornTime",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const UserProfile: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4 p-4 border rounded-lg">
|
||||
<div className="h-8 w-8 bg-primary rounded-full flex items-center justify-center text-primary-foreground font-semibold text-sm">
|
||||
JD
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">John Doe</p>
|
||||
<p className="text-sm text-muted-foreground">john@example.com</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>View Profile</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Account Settings</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Sign Out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "User profile card with dropdown menu actions",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const QualitySelector: Story = {
|
||||
render: () => (
|
||||
<div className="p-4 border rounded-lg space-y-3">
|
||||
<h3 className="font-medium">Video Settings</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Quality</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
1080p Full HD
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Select Quality</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Auto</DropdownMenuItem>
|
||||
<DropdownMenuItem>720p HD</DropdownMenuItem>
|
||||
<DropdownMenuItem>1080p Full HD</DropdownMenuItem>
|
||||
<DropdownMenuItem>1440p QHD</DropdownMenuItem>
|
||||
<DropdownMenuItem>4K Ultra HD</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Quality selector dropdown in settings",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LibraryFilter: Story = {
|
||||
render: () => (
|
||||
<div className="p-4 border rounded-lg space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">My Library</h3>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Sort by Date
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>Sort Options</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Date Added (Newest)</DropdownMenuItem>
|
||||
<DropdownMenuItem>Date Added (Oldest)</DropdownMenuItem>
|
||||
<DropdownMenuItem>Alphabetical (A-Z)</DropdownMenuItem>
|
||||
<DropdownMenuItem>Alphabetical (Z-A)</DropdownMenuItem>
|
||||
<DropdownMenuItem>Rating (Highest)</DropdownMenuItem>
|
||||
<DropdownMenuItem>Rating (Lowest)</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Recently Watched</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{["Inception", "The Matrix", "Interstellar"].map(movie => (
|
||||
<div key={movie} className="flex items-center justify-between p-2 hover:bg-muted rounded">
|
||||
<span className="text-sm">{movie}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Watch
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Star className="mr-2 h-4 w-4" />
|
||||
Add to Favorites
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Library view with sort dropdown and per-item action menus",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NavigationMenu: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-6 w-6 bg-primary rounded-sm" />
|
||||
<span className="font-semibold">PopcornTime</span>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost">Menu</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Browse Movies</span>
|
||||
<DropdownMenuShortcut>⌘M</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Browse TV Shows</span>
|
||||
<DropdownMenuShortcut>⌘T</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Star className="mr-2 h-4 w-4" />
|
||||
<span>Favorites</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<span>Downloads</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Preferences</span>
|
||||
<DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Sign Out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
story: "Application navigation dropdown menu",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ContextMenu: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Right-click on the movie cards below to see context menus
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{["The Batman", "Dune", "Spider-Man: No Way Home", "Top Gun: Maverick"].map(movie => (
|
||||
<DropdownMenu key={movie}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="p-4 border rounded-lg hover:bg-muted cursor-pointer">
|
||||
<div className="aspect-[2/3] bg-muted rounded mb-2" />
|
||||
<h4 className="font-medium text-sm">{movie}</h4>
|
||||
<p className="text-xs text-muted-foreground">2022 • Action</p>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<span>Watch Now</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Star className="mr-2 h-4 w-4" />
|
||||
<span>Add to Favorites</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Share className="mr-2 h-4 w-4" />
|
||||
<span>Share</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<span>Movie Details</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Context menus on movie cards for quick actions",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,257 +1,227 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@popcorntime/ui/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
|
||||
@@ -1,166 +1,149 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { Label } from "@popcorntime/ui/components/label";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import type * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Controller,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
} from "react-hook-form";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
);
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
);
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId();
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div data-slot="form-item" className={cn("grid gap-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : props.children;
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
|
||||
160
packages/popcorntime-ui/src/components/input.stories.tsx
Normal file
160
packages/popcorntime-ui/src/components/input.stories.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Input } from "@popcorntime/ui/components/input";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Input",
|
||||
component: Input,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A styled input component with focus states, validation styling, and file upload support.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
type: {
|
||||
control: { type: "select" },
|
||||
options: ["text", "email", "password", "number", "tel", "url", "search", "file"],
|
||||
description: "HTML input type",
|
||||
},
|
||||
placeholder: {
|
||||
control: "text",
|
||||
description: "Placeholder text",
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
description: "Whether the input is disabled",
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Input>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
placeholder: "Enter text...",
|
||||
},
|
||||
};
|
||||
|
||||
export const Types: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-80">
|
||||
<Input type="text" placeholder="Text input" />
|
||||
<Input type="email" placeholder="Email input" />
|
||||
<Input type="password" placeholder="Password input" />
|
||||
<Input type="number" placeholder="Number input" />
|
||||
<Input type="search" placeholder="Search input" />
|
||||
<Input type="tel" placeholder="Phone input" />
|
||||
<Input type="url" placeholder="URL input" />
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Different input types with appropriate placeholders",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const States: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-80">
|
||||
<Input placeholder="Default state" />
|
||||
<Input placeholder="Disabled state" disabled />
|
||||
<Input placeholder="With value" defaultValue="Some text content" />
|
||||
<Input placeholder="Focus to see ring" />
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Different input states including disabled and focused",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Validation: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-80">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Valid input</label>
|
||||
<Input placeholder="This is valid" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Invalid input</label>
|
||||
<Input placeholder="This has errors" aria-invalid={true} />
|
||||
<p className="text-sm text-destructive mt-1">This field has an error</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Input validation states using aria-invalid attribute",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FileUpload: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-80">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">File upload</label>
|
||||
<Input type="file" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Multiple files</label>
|
||||
<Input type="file" multiple />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Image files only</label>
|
||||
<Input type="file" accept="image/*" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "File upload inputs with different configurations",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabel: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-80">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email address
|
||||
</label>
|
||||
<Input id="email" type="email" placeholder="john@example.com" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<Input id="password" type="password" placeholder="Enter your password" required />
|
||||
<p className="text-xs text-muted-foreground">Must be at least 8 characters long</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Input components with proper labels and helper text",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,21 +1,20 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@popcorntime/ui/lib/utils"
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import type * as React from "react";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
207
packages/popcorntime-ui/src/components/label.stories.tsx
Normal file
207
packages/popcorntime-ui/src/components/label.stories.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Checkbox } from "@popcorntime/ui/components/checkbox";
|
||||
import { Input } from "@popcorntime/ui/components/input";
|
||||
import { Label } from "@popcorntime/ui/components/label";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Label",
|
||||
component: Label,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A label component built with Radix UI primitives, providing proper accessibility and styling for form elements.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
htmlFor: {
|
||||
control: "text",
|
||||
description: "The id of the form element this label is associated with",
|
||||
},
|
||||
children: {
|
||||
control: "text",
|
||||
description: "Label content",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Label>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: "Label text",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInput: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input id="email" type="email" placeholder="Enter your email" />
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Label properly associated with an input field using htmlFor and id",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCheckbox: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="terms" />
|
||||
<Label htmlFor="terms">Accept terms and conditions</Label>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Label used with a checkbox component",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required-field">
|
||||
Username
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input id="required-field" placeholder="Enter username" required />
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Label indicating a required field with asterisk",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" placeholder="Enter password" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Must be at least 8 characters long and contain a number
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Label with additional description text below the input",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FormGroup: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first-name">
|
||||
First Name
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="first-name" placeholder="Enter first name" required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last-name">
|
||||
Last Name
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input id="last-name" placeholder="Enter last name" required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio (optional)</Label>
|
||||
<textarea
|
||||
id="bio"
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Tell us about yourself"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="newsletter" />
|
||||
<Label htmlFor="newsletter">Subscribe to our newsletter</Label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Multiple form fields with properly associated labels",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled-input">Disabled Field</Label>
|
||||
<Input id="disabled-input" placeholder="Cannot edit" disabled />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="disabled-checkbox" disabled />
|
||||
<Label htmlFor="disabled-checkbox">Disabled option</Label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Labels with disabled form elements showing proper styling inheritance",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Validation: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="valid-input" className="text-green-600">
|
||||
Valid Field ✓
|
||||
</Label>
|
||||
<Input id="valid-input" value="john@example.com" className="border-green-500" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invalid-input" className="text-destructive">
|
||||
Invalid Field ✗
|
||||
</Label>
|
||||
<Input
|
||||
id="invalid-input"
|
||||
value="invalid-email"
|
||||
className="border-destructive"
|
||||
aria-invalid={true}
|
||||
/>
|
||||
<p className="text-xs text-destructive">Please enter a valid email address</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Labels styled to indicate validation states",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,22 +1,18 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import type * as React from "react";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
|
||||
376
packages/popcorntime-ui/src/components/menubar.stories.tsx
Normal file
376
packages/popcorntime-ui/src/components/menubar.stories.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Download, Edit, File, Film, LogOut, Settings, Star, Tv, User } from "lucide-react";
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarTrigger,
|
||||
} from "@popcorntime/ui/components/menubar";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Menubar",
|
||||
component: Menubar,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component: "A horizontally oriented menu bar with multiple dropdown menus.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof Menubar>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>File</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
New Tab
|
||||
<MenubarShortcut>⌘T</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
New Window
|
||||
<MenubarShortcut>⌘N</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>Share</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>Print</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Edit</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
Undo
|
||||
<MenubarShortcut>⌘Z</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
Redo
|
||||
<MenubarShortcut>⇧⌘Z</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>
|
||||
Find
|
||||
<MenubarShortcut>⌘F</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>View</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>Always Show Bookmarks Bar</MenubarItem>
|
||||
<MenubarItem>Always Show Full URLs</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>Reload</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
),
|
||||
};
|
||||
|
||||
export const PopcornTimeHeader: Story = {
|
||||
render: () => (
|
||||
<div className="w-full border-b">
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-6 w-6 bg-primary rounded-sm flex items-center justify-center">
|
||||
<Film className="h-4 w-4 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="font-semibold text-sm">PopcornTime</span>
|
||||
</div>
|
||||
|
||||
<Menubar className="border-none">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="px-3 py-1.5">Media</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
<Film className="mr-2 h-4 w-4" />
|
||||
Browse Movies
|
||||
<MenubarShortcut>⌘M</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
<Tv className="mr-2 h-4 w-4" />
|
||||
Browse TV Shows
|
||||
<MenubarShortcut>⌘T</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>
|
||||
<Star className="mr-2 h-4 w-4" />
|
||||
Favorites
|
||||
<MenubarShortcut>⌘F</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Downloads
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="px-3 py-1.5">Tools</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
Search
|
||||
<MenubarShortcut>/</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
Command Palette
|
||||
<MenubarShortcut>⌘K</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>Clear Cache</MenubarItem>
|
||||
<MenubarItem>Reset Settings</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="px-3 py-1.5">Account</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Preferences
|
||||
<MenubarShortcut>⌘,</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
story: "PopcornTime application header with menubar navigation",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcons: Story = {
|
||||
render: () => (
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>
|
||||
<File className="mr-2 h-4 w-4" />
|
||||
File
|
||||
</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
<File className="mr-2 h-4 w-4" />
|
||||
New File
|
||||
<MenubarShortcut>⌘N</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
<MenubarShortcut>⌘O</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Save
|
||||
<MenubarShortcut>⌘S</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>Undo</MenubarItem>
|
||||
<MenubarItem>Redo</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>Cut</MenubarItem>
|
||||
<MenubarItem>Copy</MenubarItem>
|
||||
<MenubarItem>Paste</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Menubar with icons in triggers and menu items",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleMenu: Story = {
|
||||
render: () => (
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Options</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>Settings</MenubarItem>
|
||||
<MenubarItem>Help</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>About</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Menubar with a single menu",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongMenus: Story = {
|
||||
render: () => (
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Media Types</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>Action Movies</MenubarItem>
|
||||
<MenubarItem>Adventure Movies</MenubarItem>
|
||||
<MenubarItem>Comedy Movies</MenubarItem>
|
||||
<MenubarItem>Drama Movies</MenubarItem>
|
||||
<MenubarItem>Horror Movies</MenubarItem>
|
||||
<MenubarItem>Romance Movies</MenubarItem>
|
||||
<MenubarItem>Sci-Fi Movies</MenubarItem>
|
||||
<MenubarItem>Thriller Movies</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>TV Shows</MenubarItem>
|
||||
<MenubarItem>Documentaries</MenubarItem>
|
||||
<MenubarItem>Anime</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Quality</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>720p HD</MenubarItem>
|
||||
<MenubarItem>1080p Full HD</MenubarItem>
|
||||
<MenubarItem>1440p QHD</MenubarItem>
|
||||
<MenubarItem>2160p 4K UHD</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Menubar with longer menus showing media categories",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledItems: Story = {
|
||||
render: () => (
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Actions</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>Available Action</MenubarItem>
|
||||
<MenubarItem disabled>Disabled Action</MenubarItem>
|
||||
<MenubarItem>Another Available Action</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem disabled>
|
||||
Premium Feature
|
||||
<MenubarShortcut>Pro</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Menubar with disabled menu items",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomShortcuts: Story = {
|
||||
render: () => (
|
||||
<Menubar>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Navigation</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
Go Back
|
||||
<MenubarShortcut>⌘←</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
Go Forward
|
||||
<MenubarShortcut>⌘→</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>
|
||||
Home
|
||||
<MenubarShortcut>⌘H</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
Search
|
||||
<MenubarShortcut>⌘/</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Playback</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem>
|
||||
Play/Pause
|
||||
<MenubarShortcut>Space</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
Skip Forward
|
||||
<MenubarShortcut>→</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
Skip Backward
|
||||
<MenubarShortcut>←</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem>
|
||||
Full Screen
|
||||
<MenubarShortcut>F</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem>
|
||||
Mute
|
||||
<MenubarShortcut>M</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Menubar with custom keyboard shortcuts for PopcornTime actions",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,252 +1,223 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import * as React from 'react'
|
||||
import * as MenubarPrimitive from '@radix-ui/react-menubar'
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||
import { cn } from '@popcorntime/ui/lib/utils'
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu {...props} />
|
||||
function MenubarMenu({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu {...props} />;
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group {...props} />
|
||||
function MenubarGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group {...props} />;
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal {...props} />
|
||||
function MenubarPortal({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal {...props} />;
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return <MenubarPrimitive.RadioGroup {...props} />
|
||||
function MenubarRadioGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return <MenubarPrimitive.RadioGroup {...props} />;
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
||||
function MenubarSub({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
|
||||
}
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('flex', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName
|
||||
<MenubarPrimitive.Root ref={ref} className={cn("flex", className)} {...props} />
|
||||
));
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
))
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(
|
||||
(
|
||||
{ className, align = 'start', alignOffset = -4, sideOffset = 8, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
),
|
||||
)
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
));
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
))
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
));
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
))
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
));
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
MenubarShortcut.displayname = 'MenubarShortcut'
|
||||
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
MenubarShortcut.displayname = "MenubarShortcut";
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
}
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
};
|
||||
|
||||
@@ -1,167 +1,160 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import type * as React from "react";
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean;
|
||||
viewport?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn("group flex flex-1 list-none items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={cn("absolute top-full left-0 isolate z-50 flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
};
|
||||
|
||||
@@ -1,45 +1,39 @@
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import type * as React from "react";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
|
||||
276
packages/popcorntime-ui/src/components/poster.stories.tsx
Normal file
276
packages/popcorntime-ui/src/components/poster.stories.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
MediaPosterAsPicture,
|
||||
Poster,
|
||||
PosterSkeleton,
|
||||
} from "@popcorntime/ui/components/poster";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Poster",
|
||||
component: Poster,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Media poster component for displaying movie and TV show poster images with proper fallback handling.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
isAboveTheFold: {
|
||||
control: "boolean",
|
||||
description:
|
||||
"Whether the poster is above the fold for lazy loading optimization",
|
||||
},
|
||||
withFreeBadge: {
|
||||
control: "boolean",
|
||||
description: 'Whether to show a "Free" badge on the poster',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Poster>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
media: {
|
||||
poster: "/o/gcWBYozxHjVf2oBjmddg.jpg",
|
||||
title: "The Matrix",
|
||||
overview:
|
||||
"A computer hacker learns from mysterious rebels about the true nature of his reality and his role in the war against its controllers.",
|
||||
},
|
||||
translations: {
|
||||
free: "Free",
|
||||
kind: "Movie",
|
||||
},
|
||||
isAboveTheFold: true,
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="w-48">
|
||||
<Poster {...args} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithFreeBadge: Story = {
|
||||
args: {
|
||||
media: {
|
||||
poster: "/o/gcWBYozxHjVf2oBjmddg.jpg",
|
||||
title: "Inception",
|
||||
overview:
|
||||
"A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a C.E.O.",
|
||||
},
|
||||
translations: {
|
||||
free: "Free",
|
||||
kind: "Movie",
|
||||
},
|
||||
withFreeBadge: true,
|
||||
isAboveTheFold: true,
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="w-48">
|
||||
<Poster {...args} />
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Poster with a "Free" ribbon badge',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoPoster: Story = {
|
||||
args: {
|
||||
media: {
|
||||
title: "Unknown Movie",
|
||||
overview:
|
||||
"This movie has no poster available, showing the fallback behavior.",
|
||||
},
|
||||
translations: {
|
||||
free: "Free",
|
||||
kind: "Movie",
|
||||
},
|
||||
placeholder: "https://placehold.co/300x450/374151/f3f4f6?text=No+Poster",
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="w-48">
|
||||
<Poster {...args} />
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Poster without image showing placeholder",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TVShow: Story = {
|
||||
args: {
|
||||
media: {
|
||||
poster: "/o/LSelmJnbp3xCb8RZDe4d.jpg",
|
||||
title: "Stranger Things",
|
||||
overview:
|
||||
"When a young boy disappears, his mother, a police chief and his friends must confront terrifying supernatural forces in order to get him back.",
|
||||
},
|
||||
translations: {
|
||||
free: "Free",
|
||||
kind: "TV Show",
|
||||
},
|
||||
withFreeBadge: true,
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="w-48">
|
||||
<Poster {...args} />
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "TV show poster with different kind translation",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MovieGrid: Story = {
|
||||
args: {
|
||||
media: {
|
||||
poster: "/o/gcWBYozxHjVf2oBjmddg.jpg",
|
||||
title: "The Dark Knight",
|
||||
overview:
|
||||
"Batman raises the stakes in his war on crime with the help of Lt. Jim Gordon and DA Harvey Dent.",
|
||||
},
|
||||
translations: {
|
||||
free: "Free",
|
||||
kind: "Movie",
|
||||
},
|
||||
isAboveTheFold: true,
|
||||
withFreeBadge: true,
|
||||
},
|
||||
render: (args) => {
|
||||
const movies = [
|
||||
{
|
||||
poster: "/o/gcWBYozxHjVf2oBjmddg.jpg",
|
||||
title: "The Dark Knight",
|
||||
overview:
|
||||
"Batman raises the stakes in his war on crime with the help of Lt. Jim Gordon and DA Harvey Dent.",
|
||||
},
|
||||
{
|
||||
poster: "/o/Gym3XOrLUrUbGxT29L33.jpg",
|
||||
title: "Pulp Fiction",
|
||||
overview:
|
||||
"The lives of two mob hitmen, a boxer, a gangster and his wife intertwine in four tales of violence.",
|
||||
},
|
||||
{
|
||||
poster: "/o/LSelmJnbp3xCb8RZDe4d.jpg",
|
||||
title: "Fight Club",
|
||||
overview:
|
||||
"An insomniac office worker and a devil-may-care soap maker form an underground fight club.",
|
||||
},
|
||||
{
|
||||
poster: "/o/TFKZLN5U1OpxkMvOcVsU.jpg",
|
||||
title: "Goodfellas",
|
||||
overview:
|
||||
"The story of Henry Hill and his life in the mob, covering his relationship with his wife.",
|
||||
},
|
||||
];
|
||||
|
||||
const translations = {
|
||||
free: "Free",
|
||||
kind: "Movie",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{movies.map((movie, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Poster
|
||||
{...args}
|
||||
media={movie}
|
||||
translations={translations}
|
||||
withFreeBadge={i % 2 === 0}
|
||||
isAboveTheFold={i < 2}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<h3 className="font-medium text-sm line-clamp-2">
|
||||
{movie.title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Grid of movie posters with hover effects and free badges",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DirectPicture: Story = {
|
||||
args: {
|
||||
media: {
|
||||
poster: "",
|
||||
title: "",
|
||||
overview: "",
|
||||
},
|
||||
translations: {
|
||||
free: "Free",
|
||||
kind: "Movie",
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium">Direct Picture Component</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<MediaPosterAsPicture
|
||||
posterId="gcWBYozxHjVf2oBjmddg"
|
||||
title="Movie with Poster ID"
|
||||
loading="eager"
|
||||
className="w-full aspect-[2/3] rounded-lg"
|
||||
/>
|
||||
<p className="text-sm text-center">With Poster ID</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<MediaPosterAsPicture
|
||||
title="Movie without Poster ID"
|
||||
loading="lazy"
|
||||
placeholder="https://placehold.co/300x450/374151/f3f4f6?text=Fallback"
|
||||
className="w-full aspect-[2/3] rounded-lg"
|
||||
/>
|
||||
<p className="text-sm text-center">Fallback Image</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<MediaPosterAsPicture
|
||||
posterId="Gym3XOrLUrUbGxT29L33"
|
||||
title="Lazy Loaded Movie"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[2/3] rounded-lg"
|
||||
/>
|
||||
<p className="text-sm text-center">Lazy Loaded</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Direct usage of MediaPosterAsPicture component with different configurations",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,124 +1,112 @@
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function PosterSkeleton() {
|
||||
return (
|
||||
<div className="group relative h-full w-full rounded-xs border border-transparent">
|
||||
<div className="aspect-[2/3] w-full overflow-hidden rounded-xs">
|
||||
<div className="h-full w-full animate-pulse bg-muted/60" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="group relative h-full w-full rounded-xs border border-transparent">
|
||||
<div className="aspect-[2/3] w-full overflow-hidden rounded-xs">
|
||||
<div className="h-full w-full animate-pulse bg-muted/60" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Media {
|
||||
poster?: string | null;
|
||||
title: string;
|
||||
overview?: string | null;
|
||||
poster?: string | null;
|
||||
title: string;
|
||||
overview?: string | null;
|
||||
}
|
||||
|
||||
export function Poster({
|
||||
media,
|
||||
placeholder,
|
||||
isAboveTheFold = false,
|
||||
translations,
|
||||
withFreeBadge = false,
|
||||
media,
|
||||
placeholder,
|
||||
isAboveTheFold = false,
|
||||
translations,
|
||||
withFreeBadge = false,
|
||||
}: {
|
||||
isAboveTheFold?: boolean;
|
||||
placeholder?: string;
|
||||
media: Media;
|
||||
withFreeBadge?: boolean;
|
||||
translations: {
|
||||
free: string;
|
||||
kind: string;
|
||||
};
|
||||
isAboveTheFold?: boolean;
|
||||
placeholder?: string;
|
||||
media: Media;
|
||||
withFreeBadge?: boolean;
|
||||
translations: {
|
||||
free: string;
|
||||
kind: string;
|
||||
};
|
||||
}) {
|
||||
const posterId = useMemo(() => {
|
||||
// `/ID.jpg` is the original poster
|
||||
if (media.poster) {
|
||||
return media.poster.match(/\/([^/.]+)\./)?.[1];
|
||||
}
|
||||
}, [media.poster]);
|
||||
|
||||
return (
|
||||
<div className="group relative w-full rounded-xs border border-border h-full">
|
||||
<div className="absolute -inset-px rounded-xs border-2 border-transparent opacity-0 [--quick-links-hover-bg:theme(colors.slate.800)] [background:linear-gradient(var(--quick-links-hover-bg,theme(colors.sky.50)),var(--quick-links-hover-bg,theme(colors.sky.50)))_padding-box,linear-gradient(to_top,theme(colors.slate.400),theme(colors.cyan.400),theme(colors.sky.500))_border-box] group-hover:opacity-100" />
|
||||
<div className="relative overflow-hidden rounded-xs h-full">
|
||||
{withFreeBadge && (
|
||||
<div className="absolute right-0 top-0 h-16 w-20">
|
||||
<div className="absolute -right-12 top-4 w-40 rotate-45 transform bg-[#e54b3f] py-1 text-center text-sm font-bold uppercase tracking-tighter text-white shadow-md">
|
||||
{translations.free}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
const posterId = useMemo(() => {
|
||||
// `/ID.jpg` is the original poster
|
||||
if (media.poster) {
|
||||
return media.poster.match(/\/([^/.]+)\./)?.[1];
|
||||
}
|
||||
}, [media.poster]);
|
||||
<MediaPosterAsPicture
|
||||
loading={isAboveTheFold ? "eager" : "lazy"}
|
||||
title={media.title}
|
||||
posterId={posterId}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div className="group relative w-full rounded-xs border border-border h-full">
|
||||
<div className="absolute -inset-px rounded-xs border-2 border-transparent opacity-0 [--quick-links-hover-bg:theme(colors.slate.800)] [background:linear-gradient(var(--quick-links-hover-bg,theme(colors.sky.50)),var(--quick-links-hover-bg,theme(colors.sky.50)))_padding-box,linear-gradient(to_top,theme(colors.slate.400),theme(colors.cyan.400),theme(colors.sky.500))_border-box] group-hover:opacity-100" />
|
||||
<div className="relative overflow-hidden rounded-xs h-full">
|
||||
{withFreeBadge && (
|
||||
<div className="absolute right-0 top-0 h-16 w-20">
|
||||
<div className="absolute -right-12 top-4 w-40 rotate-45 transform bg-[#e54b3f] py-1 text-center text-sm font-bold uppercase tracking-tighter text-white shadow-md">
|
||||
{translations.free}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MediaPosterAsPicture
|
||||
loading={isAboveTheFold ? "eager" : "lazy"}
|
||||
title={media.title}
|
||||
posterId={posterId}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
{media.overview && (
|
||||
<div className="absolute inset-0 bg-slate-900/80 p-4 text-white opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<div className="text-3xl font-extrabold tracking-tight">
|
||||
{translations.kind}
|
||||
</div>
|
||||
<p className="lg:line-clamp-8 mt-2 line-clamp-[10] text-sm tracking-tight sm:line-clamp-4 md:line-clamp-6 xl:line-clamp-4 2xl:line-clamp-6 portrait:line-clamp-5">
|
||||
{media.overview}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{media.overview && (
|
||||
<div className="absolute inset-0 bg-slate-900/80 p-4 text-white opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<div className="text-3xl font-extrabold tracking-tight">{translations.kind}</div>
|
||||
<p className="lg:line-clamp-8 mt-2 line-clamp-[10] text-sm tracking-tight sm:line-clamp-4 md:line-clamp-6 xl:line-clamp-4 2xl:line-clamp-6 portrait:line-clamp-5">
|
||||
{media.overview}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaPosterAsPicture({
|
||||
posterId,
|
||||
loading,
|
||||
className,
|
||||
title,
|
||||
placeholder,
|
||||
posterId,
|
||||
loading,
|
||||
className,
|
||||
title,
|
||||
placeholder,
|
||||
}: {
|
||||
posterId?: string;
|
||||
loading: "lazy" | "eager";
|
||||
className?: string;
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
posterId?: string;
|
||||
loading: "lazy" | "eager";
|
||||
className?: string;
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
if (!posterId) {
|
||||
return (
|
||||
<img
|
||||
src={placeholder}
|
||||
alt={title}
|
||||
loading={loading}
|
||||
className={cn("w-full bg-cover", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!posterId) {
|
||||
return (
|
||||
<img
|
||||
src={placeholder}
|
||||
alt={title}
|
||||
loading={loading}
|
||||
className={cn("w-full bg-cover", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<picture>
|
||||
<source
|
||||
srcSet={`https://img.popcorntime.app/o/${posterId}@300.webp`}
|
||||
type="image/webp"
|
||||
/>
|
||||
<source
|
||||
srcSet={`https://img.popcorntime.app/o/${posterId}@300.jpg`}
|
||||
type="image/jpeg"
|
||||
/>
|
||||
<img
|
||||
alt={title}
|
||||
src={`https://img.popcorntime.app/o/${posterId}@300.jpg`}
|
||||
className={cn("w-full bg-cover", className)}
|
||||
loading={loading}
|
||||
fetchPriority={loading === "eager" ? "high" : undefined}
|
||||
/>
|
||||
</picture>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<picture>
|
||||
<source srcSet={`https://img.popcorntime.app/o/${posterId}@300.webp`} type="image/webp" />
|
||||
<source srcSet={`https://img.popcorntime.app/o/${posterId}@300.jpg`} type="image/jpeg" />
|
||||
<img
|
||||
alt={title}
|
||||
src={`https://img.popcorntime.app/o/${posterId}@300.jpg`}
|
||||
className={cn("w-full bg-cover", className)}
|
||||
loading={loading}
|
||||
fetchPriority={loading === "eager" ? "high" : undefined}
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
|
||||
351
packages/popcorntime-ui/src/components/scroll-area.stories.tsx
Normal file
351
packages/popcorntime-ui/src/components/scroll-area.stories.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ScrollArea, ScrollBar } from "@popcorntime/ui/components/scroll-area";
|
||||
import { Separator } from "@popcorntime/ui/components/separator";
|
||||
|
||||
const meta = {
|
||||
title: "Components/ScrollArea",
|
||||
component: ScrollArea,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component: "A custom scrollable area with styled scrollbars that can be hidden or shown.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof ScrollArea>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<ScrollArea className="h-72 w-48 rounded-md border">
|
||||
<div className="p-4">
|
||||
<h4 className="mb-4 text-sm font-medium leading-none">Tags</h4>
|
||||
{Array.from({ length: 50 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="text-sm">v1.2.0-beta.{i + 1}</div>
|
||||
<Separator className="my-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
),
|
||||
};
|
||||
|
||||
export const MovieList: Story = {
|
||||
render: () => {
|
||||
const movies = [
|
||||
{ title: "The Shawshank Redemption", year: 1994, genre: "Drama", rating: 9.3 },
|
||||
{ title: "The Godfather", year: 1972, genre: "Crime", rating: 9.2 },
|
||||
{ title: "The Dark Knight", year: 2008, genre: "Action", rating: 9.0 },
|
||||
{ title: "The Godfather Part II", year: 1974, genre: "Crime", rating: 9.0 },
|
||||
{ title: "Angry Men", year: 1957, genre: "Drama", rating: 9.0 },
|
||||
{ title: "Schindler's List", year: 1993, genre: "Drama", rating: 8.9 },
|
||||
{
|
||||
title: "The Lord of the Rings: The Return of the King",
|
||||
year: 2003,
|
||||
genre: "Fantasy",
|
||||
rating: 8.9,
|
||||
},
|
||||
{ title: "Pulp Fiction", year: 1994, genre: "Crime", rating: 8.8 },
|
||||
{
|
||||
title: "The Lord of the Rings: The Fellowship of the Ring",
|
||||
year: 2001,
|
||||
genre: "Fantasy",
|
||||
rating: 8.8,
|
||||
},
|
||||
{ title: "The Good, the Bad and the Ugly", year: 1966, genre: "Western", rating: 8.8 },
|
||||
{ title: "Forrest Gump", year: 1994, genre: "Drama", rating: 8.8 },
|
||||
{ title: "The Lord of the Rings: The Two Towers", year: 2002, genre: "Fantasy", rating: 8.7 },
|
||||
{ title: "Fight Club", year: 1999, genre: "Drama", rating: 8.7 },
|
||||
{ title: "Inception", year: 2010, genre: "Sci-Fi", rating: 8.7 },
|
||||
{ title: "The Matrix", year: 1999, genre: "Sci-Fi", rating: 8.7 },
|
||||
{ title: "Goodfellas", year: 1990, genre: "Crime", rating: 8.7 },
|
||||
{ title: "Seven Samurai", year: 1954, genre: "Action", rating: 8.6 },
|
||||
{ title: "Star Wars: Episode IV - A New Hope", year: 1977, genre: "Sci-Fi", rating: 8.6 },
|
||||
{ title: "City of God", year: 2002, genre: "Crime", rating: 8.6 },
|
||||
{ title: "Casablanca", year: 1942, genre: "Romance", rating: 8.5 },
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-96 w-full max-w-md rounded-md border">
|
||||
<div className="p-4">
|
||||
<h4 className="mb-4 text-sm font-medium leading-none">Top Movies</h4>
|
||||
{movies.map((movie, i) => (
|
||||
<div key={i}>
|
||||
<div className="py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{movie.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{movie.year} • {movie.genre}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs font-medium">⭐ {movie.rating}</div>
|
||||
</div>
|
||||
</div>
|
||||
{i < movies.length - 1 && <Separator />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Scrollable movie list with movie information",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const HorizontalScroll: Story = {
|
||||
render: () => {
|
||||
const genres = [
|
||||
"Action",
|
||||
"Adventure",
|
||||
"Animation",
|
||||
"Comedy",
|
||||
"Crime",
|
||||
"Documentary",
|
||||
"Drama",
|
||||
"Family",
|
||||
"Fantasy",
|
||||
"History",
|
||||
"Horror",
|
||||
"Music",
|
||||
"Mystery",
|
||||
"Romance",
|
||||
"Science Fiction",
|
||||
"TV Movie",
|
||||
"Thriller",
|
||||
"War",
|
||||
"Western",
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollArea className="w-96 whitespace-nowrap rounded-md border">
|
||||
<div className="flex w-max space-x-4 p-4">
|
||||
{genres.map(genre => (
|
||||
<div key={genre} className="shrink-0 rounded-md border px-3 py-2 text-sm">
|
||||
{genre}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Horizontal scrolling area for genre tags with visible scrollbar",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ThumbnailGrid: Story = {
|
||||
render: () => (
|
||||
<ScrollArea className="h-72 w-80 rounded-md border">
|
||||
<div className="p-4">
|
||||
<h4 className="mb-4 text-sm font-medium leading-none">Movie Posters</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-[2/3] bg-muted rounded-md flex items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
Movie {i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Scrollable grid of movie poster thumbnails",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WatchHistory: Story = {
|
||||
render: () => {
|
||||
const watchHistory = Array.from({ length: 20 }).map((_, i) => ({
|
||||
title: `Movie ${i + 1}`,
|
||||
watchedAt: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toLocaleDateString(),
|
||||
progress: Math.floor(Math.random() * 100),
|
||||
}));
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-80 w-72 rounded-md border">
|
||||
<div className="p-4">
|
||||
<h4 className="mb-4 text-sm font-medium leading-none">Watch History</h4>
|
||||
<div className="space-y-3">
|
||||
{watchHistory.map((item, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{item.title}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.watchedAt}</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-primary h-1.5 rounded-full"
|
||||
style={{ width: `${item.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{item.progress}% watched</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Watch history with progress bars in a scrollable area",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ChatMessages: Story = {
|
||||
render: () => {
|
||||
const messages = Array.from({ length: 15 }).map((_, i) => ({
|
||||
user: ["Alice", "Bob", "Charlie", "Diana"][i % 4],
|
||||
message: `This is message ${i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit.`,
|
||||
time: `${9 + Math.floor(i / 3)}:${(i * 7) % 60}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg">
|
||||
<div className="p-3 border-b">
|
||||
<h4 className="text-sm font-medium">Live Chat</h4>
|
||||
</div>
|
||||
<ScrollArea className="h-64">
|
||||
<div className="p-3 space-y-3">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className="text-sm">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-primary">{msg.user}</span>
|
||||
<span className="text-xs text-muted-foreground">{msg.time}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1">{msg.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="p-3 border-t">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 px-3 py-1 text-sm border rounded"
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
<button className="px-3 py-1 text-sm bg-primary text-primary-foreground rounded">
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Chat interface with scrollable message history",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FileExplorer: Story = {
|
||||
render: () => {
|
||||
const files = [
|
||||
{ name: "Movies", type: "folder", size: "", modified: "2024-01-15" },
|
||||
{ name: "TV Shows", type: "folder", size: "", modified: "2024-01-14" },
|
||||
{ name: "Downloads", type: "folder", size: "", modified: "2024-01-13" },
|
||||
{ name: "Inception.mkv", type: "video", size: "2.8 GB", modified: "2024-01-12" },
|
||||
{ name: "The Matrix.mp4", type: "video", size: "1.9 GB", modified: "2024-01-11" },
|
||||
{ name: "Interstellar.mkv", type: "video", size: "3.2 GB", modified: "2024-01-10" },
|
||||
{ name: "The Dark Knight.mp4", type: "video", size: "2.1 GB", modified: "2024-01-09" },
|
||||
{ name: "Pulp Fiction.mkv", type: "video", size: "2.5 GB", modified: "2024-01-08" },
|
||||
{ name: "Fight Club.mp4", type: "video", size: "1.8 GB", modified: "2024-01-07" },
|
||||
{ name: "Goodfellas.mkv", type: "video", size: "2.3 GB", modified: "2024-01-06" },
|
||||
{ name: "The Godfather.mp4", type: "video", size: "2.0 GB", modified: "2024-01-05" },
|
||||
{ name: "Casablanca.mkv", type: "video", size: "1.2 GB", modified: "2024-01-04" },
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-80 w-96 rounded-md border">
|
||||
<div className="p-4">
|
||||
<h4 className="mb-4 text-sm font-medium leading-none">Media Library</h4>
|
||||
<div className="space-y-1">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between p-2 hover:bg-muted rounded text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 text-center">{file.type === "folder" ? "📁" : "🎬"}</div>
|
||||
<span className={file.type === "folder" ? "font-medium" : ""}>{file.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
{file.size && <span>{file.size}</span>}
|
||||
<span>{file.modified}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "File explorer interface showing media library with folders and videos",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const BothScrollbars: Story = {
|
||||
render: () => (
|
||||
<ScrollArea className="h-72 w-80 rounded-md border">
|
||||
<div className="p-4" style={{ width: "600px", height: "500px" }}>
|
||||
<h4 className="mb-4 text-sm font-medium leading-none">Large Content Area</h4>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div key={i} className="p-4 border rounded-lg">
|
||||
<h5 className="font-medium">Section {i + 1}</h5>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
This is a very long piece of content that extends beyond the normal width of the
|
||||
container. It demonstrates how the horizontal scrollbar works alongside the vertical
|
||||
one. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
|
||||
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
|
||||
exercitation ullamco.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Content that requires both horizontal and vertical scrolling",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,57 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import type * as React from "react";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
ref,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
ref,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={ref}
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={ref}
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" && "h-full w-2.5 ",
|
||||
orientation === "horizontal" && "h-2.5 flex-col ",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" && "h-full w-2.5 ",
|
||||
orientation === "horizontal" && "h-2.5 flex-col ",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
|
||||
247
packages/popcorntime-ui/src/components/separator.stories.tsx
Normal file
247
packages/popcorntime-ui/src/components/separator.stories.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Separator } from "@popcorntime/ui/components/separator";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Separator",
|
||||
component: Separator,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A separator component built with Radix UI primitives for visually or semantically separating content.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
orientation: {
|
||||
control: { type: "select" },
|
||||
options: ["horizontal", "vertical"],
|
||||
description: "The orientation of the separator",
|
||||
},
|
||||
decorative: {
|
||||
control: "boolean",
|
||||
description: "Whether the separator is purely decorative or has semantic meaning",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Separator>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Horizontal: Story = {
|
||||
render: () => (
|
||||
<div className="w-64">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium">Radix UI</h4>
|
||||
<p className="text-sm text-muted-foreground">An open-source UI component library.</p>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="flex h-5 items-center space-x-4 text-sm">
|
||||
<div>Blog</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div>Docs</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div>Source</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Horizontal separator dividing sections of content",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Vertical: Story = {
|
||||
render: () => (
|
||||
<div className="flex h-5 items-center space-x-4 text-sm">
|
||||
<div>Blog</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div>Docs</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div>Source</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div>Help</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Vertical separators between navigation items",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const InNavigation: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<nav className="flex items-center space-x-4 text-sm font-medium">
|
||||
<a href="#" className="text-foreground">
|
||||
Home
|
||||
</a>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<a href="#" className="text-muted-foreground">
|
||||
About
|
||||
</a>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<a href="#" className="text-muted-foreground">
|
||||
Services
|
||||
</a>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<a href="#" className="text-muted-foreground">
|
||||
Contact
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="text-sm text-muted-foreground">Main content area</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Separators used in navigation with both vertical and horizontal orientations",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const InCard: Story = {
|
||||
render: () => (
|
||||
<div className="w-80 rounded-lg border bg-card p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Card Title</h3>
|
||||
<span className="text-sm text-muted-foreground">$99</span>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Feature 1</span>
|
||||
<span className="text-muted-foreground">✓</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Feature 2</span>
|
||||
<span className="text-muted-foreground">✓</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Feature 3</span>
|
||||
<span className="text-muted-foreground">✗</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<button className="w-full rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground">
|
||||
Choose Plan
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Separators used within a card to divide different sections",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithContent: Story = {
|
||||
render: () => (
|
||||
<div className="w-96 space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">Account Settings</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">Email</span>
|
||||
<span className="text-sm text-muted-foreground">john@example.com</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">Phone</span>
|
||||
<span className="text-sm text-muted-foreground">+1 (555) 123-4567</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">Preferences</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">Language</span>
|
||||
<span className="text-sm text-muted-foreground">English</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">Timezone</span>
|
||||
<span className="text-sm text-muted-foreground">UTC-5</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-2">Notifications</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">Email notifications</span>
|
||||
<span className="text-sm text-muted-foreground">Enabled</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">Push notifications</span>
|
||||
<span className="text-sm text-muted-foreground">Disabled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Separators organizing different sections of a settings panel",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
render: () => (
|
||||
<div className="w-64 space-y-4">
|
||||
<div className="text-center">
|
||||
<h3 className="font-medium">Default Separator</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="font-medium">Thicker Separator</h3>
|
||||
</div>
|
||||
<Separator className="h-0.5" />
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="font-medium">Colored Separator</h3>
|
||||
</div>
|
||||
<Separator className="bg-primary" />
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="font-medium">Dashed Separator</h3>
|
||||
</div>
|
||||
<Separator className="border-t border-dashed border-border bg-transparent h-0" />
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Various separator styles including different thicknesses, colors, and border styles",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,28 +1,27 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@popcorntime/ui/lib/utils"
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import type * as React from "react";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
445
packages/popcorntime-ui/src/components/sheet.stories.tsx
Normal file
445
packages/popcorntime-ui/src/components/sheet.stories.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Bell, Download, Settings, Shield, Star, User } from "lucide-react";
|
||||
import { fn } from "storybook/test";
|
||||
import { Button } from "@popcorntime/ui/components/button";
|
||||
import { Input } from "@popcorntime/ui/components/input";
|
||||
import { Label } from "@popcorntime/ui/components/label";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@popcorntime/ui/components/sheet";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Sheet",
|
||||
component: Sheet,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A sheet dialog that slides in from the edge of the screen, commonly used for navigation menus or forms.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
open: {
|
||||
control: "boolean",
|
||||
description: "Whether the sheet is open",
|
||||
},
|
||||
onOpenChange: {
|
||||
action: "onOpenChange",
|
||||
description: "Callback when sheet open state changes",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onOpenChange: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof Sheet>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline">Open Sheet</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
<SheetDescription>
|
||||
This is a sheet dialog. It slides in from the side of the screen.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sheet content goes here. You can include forms, navigation, or any other content.
|
||||
</p>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</SheetClose>
|
||||
<Button>Save Changes</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
),
|
||||
};
|
||||
|
||||
export const LeftSide: Story = {
|
||||
render: () => (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline">Open Left Sheet</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Navigation Menu</SheetTitle>
|
||||
<SheetDescription>Navigate to different sections of the application.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="py-6">
|
||||
<nav className="space-y-2">
|
||||
<a href="#" className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-muted">
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</a>
|
||||
<a href="#" className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-muted">
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</a>
|
||||
<a href="#" className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-muted">
|
||||
<Bell className="h-4 w-4" />
|
||||
Notifications
|
||||
</a>
|
||||
<a href="#" className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-muted">
|
||||
<Shield className="h-4 w-4" />
|
||||
Privacy
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Sheet sliding in from the left side, commonly used for navigation",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TopSide: Story = {
|
||||
render: () => (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline">Open Top Sheet</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="top">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Notification Center</SheetTitle>
|
||||
<SheetDescription>Recent updates and notifications from PopcornTime.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 border rounded-lg">
|
||||
<div className="h-2 w-2 bg-blue-500 rounded-full mt-2" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">New movie added</p>
|
||||
<p className="text-xs text-muted-foreground">"The Batman" is now available</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 border rounded-lg">
|
||||
<div className="h-2 w-2 bg-green-500 rounded-full mt-2" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Download complete</p>
|
||||
<p className="text-xs text-muted-foreground">"Stranger Things S4" ready to watch</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Sheet sliding down from the top, useful for notifications or quick actions",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const BottomSide: Story = {
|
||||
render: () => (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline">Open Bottom Sheet</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Quick Actions</SheetTitle>
|
||||
<SheetDescription>Common actions you can perform quickly.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="py-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Star className="h-4 w-4" />
|
||||
Favorite
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Sheet sliding up from the bottom, great for mobile-style action sheets",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EditProfile: Story = {
|
||||
render: () => (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button>Edit Profile</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Edit Profile</SheetTitle>
|
||||
<SheetDescription>
|
||||
Make changes to your profile here. Click save when you're done.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" defaultValue="Pedro Duarte" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" defaultValue="@peduarte" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" defaultValue="pedro@example.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio</Label>
|
||||
<textarea
|
||||
id="bio"
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Tell us a little bit about yourself"
|
||||
defaultValue="I love watching movies and TV shows on PopcornTime!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</SheetClose>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Sheet containing a form for editing user profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MovieDetails: Story = {
|
||||
render: () => (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline">Movie Details</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[400px] sm:w-[540px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Inception (2010)</SheetTitle>
|
||||
<SheetDescription>Sci-Fi, Thriller • 148 min • PG-13</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="py-6 space-y-6">
|
||||
{/* Movie poster placeholder */}
|
||||
<div className="aspect-[2/3] w-48 bg-muted rounded-lg mx-auto" />
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Synopsis</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A thief who steals corporate secrets through the use of dream-sharing technology is
|
||||
given the inverse task of planting an idea into the mind of a C.E.O.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Cast</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Leonardo DiCaprio, Marion Cotillard, Tom Hardy, Ellen Page, Ken Watanabe
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Rating</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-4 w-4 ${i < 4 ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">8.8/10</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Star className="h-4 w-4" />
|
||||
Add to Favorites
|
||||
</Button>
|
||||
<Button className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Watch Now
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Sheet displaying detailed movie information with actions",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SettingsSheet: Story = {
|
||||
render: () => (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Settings</SheetTitle>
|
||||
<SheetDescription>Configure your PopcornTime preferences.</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Video Quality</h3>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Default streaming quality</Label>
|
||||
<select className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||
<option>Auto</option>
|
||||
<option>720p HD</option>
|
||||
<option selected>1080p Full HD</option>
|
||||
<option>4K Ultra HD</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Downloads</h3>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Download location</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input defaultValue="/Users/username/Downloads/PopcornTime" />
|
||||
<Button variant="outline" size="sm">
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Privacy</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">Use VPN when streaming</Label>
|
||||
<input type="checkbox" className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">Anonymous usage statistics</Label>
|
||||
<input type="checkbox" className="h-4 w-4" defaultChecked />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</SheetClose>
|
||||
<Button>Save Settings</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Settings sheet with various configuration options",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CompactSheet: Story = {
|
||||
render: () => (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Quick Actions
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[300px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Quick Actions</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="py-4">
|
||||
<div className="grid gap-2">
|
||||
<Button variant="ghost" className="justify-start gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Download Movie
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-start gap-2">
|
||||
<Star className="h-4 w-4" />
|
||||
Add to Favorites
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-start gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Set Reminder
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-start gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Preferences
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Compact sheet with quick action buttons",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,139 +1,129 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@popcorntime/ui/lib/utils"
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
|
||||
406
packages/popcorntime-ui/src/components/sidebar.stories.tsx
Normal file
406
packages/popcorntime-ui/src/components/sidebar.stories.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Film, Home, MoreHorizontal, Search, Settings, Star, Tv } from "lucide-react";
|
||||
import { fn } from "storybook/test";
|
||||
import { Badge } from "@popcorntime/ui/components/badge";
|
||||
import { Button } from "@popcorntime/ui/components/button";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@popcorntime/ui/components/sidebar";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Sidebar",
|
||||
component: SidebarProvider,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A sidebar navigation component with collapsible groups, search, and contextual content management.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
Story => (
|
||||
<div className="flex h-screen w-full">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof SidebarProvider>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// Helper component that demonstrates sidebar usage
|
||||
function SidebarExample() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-4 py-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Film className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="font-semibold">PopcornTime</span>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<Home className="h-4 w-4" />
|
||||
<span>Home</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive>
|
||||
<Film className="h-4 w-4" />
|
||||
<span>Movies</span>
|
||||
<SidebarMenuBadge>24</SidebarMenuBadge>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<Tv className="h-4 w-4" />
|
||||
<span>TV Shows</span>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton>Popular</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton>Trending</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<Star className="h-4 w-4" />
|
||||
<span>Favorites</span>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuAction>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Filters</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarInput placeholder="Search..." />
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
<SidebarInset>
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger />
|
||||
<h1 className="text-xl font-semibold">Main Content</h1>
|
||||
</div>
|
||||
<div className="min-h-[400px] rounded-lg border bg-muted/50 p-6">
|
||||
<p className="text-muted-foreground">
|
||||
Main content area. The sidebar can be collapsed using the trigger button.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <SidebarExample />,
|
||||
};
|
||||
|
||||
export const CollapsibleIcon: Story = {
|
||||
render: () => (
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Film className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton tooltip="Home">
|
||||
<Home className="h-4 w-4" />
|
||||
<span>Home</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton tooltip="Movies" isActive>
|
||||
<Film className="h-4 w-4" />
|
||||
<span>Movies</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton tooltip="TV Shows">
|
||||
<Tv className="h-4 w-4" />
|
||||
<span>TV Shows</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
<SidebarInset>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<SidebarTrigger />
|
||||
<h2 className="text-lg font-semibold">Icon Collapsible Sidebar</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
This sidebar collapses to show only icons with tooltips on hover.
|
||||
</p>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Sidebar that collapses to icon-only mode with tooltips",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSearch: Story = {
|
||||
render: () => (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
<SidebarInput placeholder="Search content..." />
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Browse</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<Film className="h-4 w-4" />
|
||||
<span>Action Movies</span>
|
||||
<SidebarMenuBadge>142</SidebarMenuBadge>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<Film className="h-4 w-4" />
|
||||
<span>Comedy Movies</span>
|
||||
<SidebarMenuBadge>87</SidebarMenuBadge>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<Tv className="h-4 w-4" />
|
||||
<span>Drama Series</span>
|
||||
<SidebarMenuBadge>23</SidebarMenuBadge>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
<SidebarInset>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<SidebarTrigger />
|
||||
<h2 className="text-lg font-semibold">Sidebar with Search</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Sidebar with search functionality and category badges.
|
||||
</p>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Sidebar with integrated search and category counts",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NestedMenus: Story = {
|
||||
render: () => (
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Media Library</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<Film className="h-4 w-4" />
|
||||
<span>Movies</span>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton isActive>Recently Added</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton>Popular</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton>Top Rated</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<Tv className="h-4 w-4" />
|
||||
<span>TV Shows</span>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton>Airing Today</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton>On the Air</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton>Popular</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
<SidebarInset>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<SidebarTrigger />
|
||||
<h2 className="text-lg font-semibold">Nested Menus</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">Hierarchical navigation with expandable submenus.</p>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Sidebar with nested submenus for hierarchical navigation",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Standalone components for testing
|
||||
export const SidebarComponents: Story = {
|
||||
render: () => (
|
||||
<SidebarProvider>
|
||||
<div className="space-y-4 p-6 max-w-sm">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Sidebar Input</h3>
|
||||
<SidebarInput placeholder="Search movies..." />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Menu Items</h3>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<Home className="h-4 w-4" />
|
||||
<span>Home</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive>
|
||||
<Film className="h-4 w-4" />
|
||||
<span>Movies</span>
|
||||
<SidebarMenuBadge>24</SidebarMenuBadge>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuAction>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Group Labels</h3>
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarSeparator className="my-2" />
|
||||
<SidebarGroupLabel>Content</SidebarGroupLabel>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
),
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
story: "Individual sidebar components for testing and customization",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
329
packages/popcorntime-ui/src/components/skeleton.stories.tsx
Normal file
329
packages/popcorntime-ui/src/components/skeleton.stories.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Avatar, AvatarFallback } from "@popcorntime/ui/components/avatar";
|
||||
import { Skeleton } from "@popcorntime/ui/components/skeleton";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Skeleton",
|
||||
component: Skeleton,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Loading skeleton component to show placeholder content while data is being fetched.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Skeleton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Shapes: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Rectangle</h3>
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Circle</h3>
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Rounded Rectangle</h3>
|
||||
<Skeleton className="h-10 w-40 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">Text Lines</h3>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Different skeleton shapes and sizes",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MovieCard: Story = {
|
||||
render: () => (
|
||||
<div className="w-64 p-4 border rounded-lg">
|
||||
<div className="space-y-4">
|
||||
{/* Movie poster */}
|
||||
<Skeleton className="h-96 w-full rounded-lg" />
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
|
||||
{/* Rating and year */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
<Skeleton className="h-3 w-4/6" />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-20" />
|
||||
<Skeleton className="h-9 w-9" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Movie card skeleton matching PopcornTime design patterns",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const UserProfile: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center space-x-4 p-4 border rounded-lg w-80">
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "User profile skeleton with avatar and text",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MediaList: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4 w-full max-w-2xl">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center space-x-4 p-4 border rounded-lg">
|
||||
{/* Thumbnail */}
|
||||
<Skeleton className="h-16 w-28 rounded" />
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
{/* Title */}
|
||||
<Skeleton className="h-5 w-48" />
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Media list skeleton for search results or watchlist",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Navigation: Story = {
|
||||
render: () => (
|
||||
<div className="w-64 p-4 border rounded-lg">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
{i === 1 && <Skeleton className="h-4 w-6 ml-auto rounded-full" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<Skeleton className="h-px w-full" />
|
||||
|
||||
{/* More menu items */}
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Navigation sidebar skeleton",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Table: Story = {
|
||||
render: () => (
|
||||
<div className="w-full max-w-4xl">
|
||||
<div className="rounded-lg border">
|
||||
{/* Table header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table rows */}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-4 border-b last:border-b-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Table skeleton with headers and rows",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CardGrid: Story = {
|
||||
render: () => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="border rounded-lg p-4 space-y-3">
|
||||
<Skeleton className="h-48 w-full rounded" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
story: "Grid of media cards skeleton for browse pages",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SearchResults: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
{/* Search bar */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<Skeleton className="h-10 flex-1 rounded-lg" />
|
||||
<Skeleton className="h-10 w-20 rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<Skeleton className="h-4 w-40" />
|
||||
|
||||
{/* Results */}
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-4 p-3 border rounded-lg">
|
||||
<Skeleton className="h-20 w-14 rounded" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-56" />
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-8" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-4/5" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Search results page skeleton",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group select-none cursor-default"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group select-none cursor-default"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster }
|
||||
export { Toaster };
|
||||
|
||||
335
packages/popcorntime-ui/src/components/spinner.stories.tsx
Normal file
335
packages/popcorntime-ui/src/components/spinner.stories.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "@popcorntime/ui/components/button";
|
||||
import { Spinner } from "@popcorntime/ui/components/spinner";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Spinner",
|
||||
component: Spinner,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component: "Loading spinner component to indicate processing or loading states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: { type: "select" },
|
||||
options: ["small", "medium", "large"],
|
||||
description: "Size variant of the spinner",
|
||||
},
|
||||
show: {
|
||||
control: "boolean",
|
||||
description: "Whether to show the spinner",
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Spinner>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="text-center">
|
||||
<Spinner size="small" />
|
||||
<p className="text-xs mt-2 text-muted-foreground">Small</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Spinner size="medium" />
|
||||
<p className="text-xs mt-2 text-muted-foreground">Medium</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Spinner size="large" />
|
||||
<p className="text-xs mt-2 text-muted-foreground">Large</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Different spinner sizes",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingButton: Story = {
|
||||
render: () => (
|
||||
<div className="flex gap-4">
|
||||
<Button disabled className="gap-2">
|
||||
<Spinner size="small" />
|
||||
Loading...
|
||||
</Button>
|
||||
<Button variant="outline" disabled className="gap-2">
|
||||
<Spinner size="small" />
|
||||
Saving
|
||||
</Button>
|
||||
<Button variant="secondary" disabled className="gap-2">
|
||||
<Spinner size="small" />
|
||||
Processing
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Spinner used in loading buttons",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MovieLoading: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col items-center gap-4 p-8">
|
||||
<div className="text-center space-y-4">
|
||||
<Spinner size="large" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Loading Movie Details</h3>
|
||||
<p className="text-sm text-muted-foreground">Fetching information for "Inception"...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Spinner used while loading movie details",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SearchLoading: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col items-center gap-4 p-8">
|
||||
<Spinner size="large" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold">Searching...</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Finding movies and TV shows matching your query
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Spinner used during search operations",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const StreamingLoading: Story = {
|
||||
render: () => (
|
||||
<div className="bg-black text-white p-12 rounded-lg text-center">
|
||||
<div className="space-y-6">
|
||||
<Spinner size="large" className="text-white" />
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">Preparing Stream</h3>
|
||||
<p className="text-sm text-gray-400">Connecting to torrent peers...</p>
|
||||
<div className="mt-4 w-64 bg-gray-800 rounded-full h-2 mx-auto">
|
||||
<div className="bg-blue-500 h-2 rounded-full w-1/3 animate-pulse"></div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">33% complete</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Spinner used while preparing video stream",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const InlineLoading: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div className="flex items-center gap-2 p-3 border rounded">
|
||||
<Spinner size="small" />
|
||||
<span className="text-sm">Loading watchlist...</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 border rounded">
|
||||
<Spinner size="small" />
|
||||
<span className="text-sm">Syncing favorites...</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 border rounded">
|
||||
<Spinner size="small" />
|
||||
<span className="text-sm">Checking for updates...</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Inline spinners for background operations",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CardLoading: Story = {
|
||||
render: () => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="border rounded-lg p-6">
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<Spinner />
|
||||
<p className="text-sm text-muted-foreground">Loading trending movies...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-6">
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<Spinner />
|
||||
<p className="text-sm text-muted-foreground">Loading TV shows...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-6">
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<Spinner />
|
||||
<p className="text-sm text-muted-foreground">Loading recommendations...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-6">
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<Spinner />
|
||||
<p className="text-sm text-muted-foreground">Loading your watchlist...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Spinners in content cards while data loads",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FullScreenLoading: Story = {
|
||||
render: () => (
|
||||
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<Spinner size="large" />
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">PopcornTime</h2>
|
||||
<p className="text-muted-foreground">Loading your entertainment...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
story: "Full screen loading overlay",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomColors: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-8 p-4">
|
||||
<div className="text-center">
|
||||
<Spinner className="text-blue-500" />
|
||||
<p className="text-xs mt-2">Blue</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Spinner className="text-green-500" />
|
||||
<p className="text-xs mt-2">Green</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Spinner className="text-red-500" />
|
||||
<p className="text-xs mt-2">Red</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Spinner className="text-purple-500" />
|
||||
<p className="text-xs mt-2">Purple</p>
|
||||
</div>
|
||||
<div className="text-center bg-black p-4 rounded">
|
||||
<Spinner className="text-white" />
|
||||
<p className="text-xs mt-2 text-white">White</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Spinner with custom colors using CSS classes",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithText: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<Spinner size="small">
|
||||
<p className="text-sm text-muted-foreground mt-2">Loading...</p>
|
||||
</Spinner>
|
||||
|
||||
<Spinner size="medium">
|
||||
<p className="text-sm text-muted-foreground mt-3">Fetching data...</p>
|
||||
</Spinner>
|
||||
|
||||
<Spinner size="large">
|
||||
<div className="mt-4 text-center">
|
||||
<p className="font-semibold">Please wait</p>
|
||||
<p className="text-sm text-muted-foreground">Loading content...</p>
|
||||
</div>
|
||||
</Spinner>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Spinner with accompanying text content",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ConditionalShow: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">Visible (show=true)</p>
|
||||
<Spinner show={true} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">Hidden (show=false)</p>
|
||||
<Spinner show={false} />
|
||||
<p className="text-xs text-muted-foreground">Spinner is hidden but space is preserved</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Conditionally showing/hiding the spinner",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,50 +1,45 @@
|
||||
import React from "react";
|
||||
import { VariantProps, cva } from "class-variance-authority";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type React from "react";
|
||||
|
||||
const spinnerVariants = cva("flex-col items-center justify-center", {
|
||||
variants: {
|
||||
show: {
|
||||
true: "flex",
|
||||
false: "hidden",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
show: true,
|
||||
},
|
||||
variants: {
|
||||
show: {
|
||||
true: "flex",
|
||||
false: "hidden",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
show: true,
|
||||
},
|
||||
});
|
||||
|
||||
const loaderVariants = cva("animate-spin text-primary", {
|
||||
variants: {
|
||||
size: {
|
||||
small: "size-6",
|
||||
medium: "size-8",
|
||||
large: "size-12",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "medium",
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
small: "size-6",
|
||||
medium: "size-8",
|
||||
large: "size-12",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "medium",
|
||||
},
|
||||
});
|
||||
|
||||
interface SpinnerContentProps
|
||||
extends VariantProps<typeof spinnerVariants>,
|
||||
VariantProps<typeof loaderVariants> {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
extends VariantProps<typeof spinnerVariants>,
|
||||
VariantProps<typeof loaderVariants> {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Spinner({
|
||||
size,
|
||||
show,
|
||||
children,
|
||||
className,
|
||||
}: SpinnerContentProps) {
|
||||
return (
|
||||
<span className={spinnerVariants({ show })}>
|
||||
<Loader2 className={cn(loaderVariants({ size }), className)} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
export function Spinner({ size, show, children, className }: SpinnerContentProps) {
|
||||
return (
|
||||
<span className={spinnerVariants({ show })}>
|
||||
<Loader2 className={cn(loaderVariants({ size }), className)} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,115 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import type * as React from "react";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
|
||||
337
packages/popcorntime-ui/src/components/tabs.stories.tsx
Normal file
337
packages/popcorntime-ui/src/components/tabs.stories.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { Button } from "@popcorntime/ui/components/button";
|
||||
import { Input } from "@popcorntime/ui/components/input";
|
||||
import { Label } from "@popcorntime/ui/components/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@popcorntime/ui/components/tabs";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Tabs",
|
||||
component: Tabs,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A tab component built with Radix UI primitives for organizing content into sections.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
defaultValue: {
|
||||
control: "text",
|
||||
description: "The default active tab",
|
||||
},
|
||||
value: {
|
||||
control: "text",
|
||||
description: "The controlled active tab value",
|
||||
},
|
||||
orientation: {
|
||||
control: { type: "select" },
|
||||
options: ["horizontal", "vertical"],
|
||||
description: "The orientation of the tabs",
|
||||
},
|
||||
onValueChange: {
|
||||
action: "onValueChange",
|
||||
description: "Callback when tab changes",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onValueChange: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof Tabs>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Tabs defaultValue="account" className="w-96">
|
||||
<TabsList>
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
<TabsTrigger value="password">Password</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="account" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" defaultValue="John Doe" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" defaultValue="@johndoe" />
|
||||
</div>
|
||||
<Button>Save changes</Button>
|
||||
</TabsContent>
|
||||
<TabsContent value="password" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current">Current password</Label>
|
||||
<Input id="current" type="password" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new">New password</Label>
|
||||
<Input id="new" type="password" />
|
||||
</div>
|
||||
<Button>Update password</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithIcons: Story = {
|
||||
render: () => (
|
||||
<Tabs defaultValue="overview" className="w-96">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"
|
||||
/>
|
||||
</svg>
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Overview</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get a quick overview of your account activity and performance metrics.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold">1,234</div>
|
||||
<div className="text-sm text-muted-foreground">Total Users</div>
|
||||
</div>
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold">567</div>
|
||||
<div className="text-sm text-muted-foreground">Active Sessions</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="analytics" className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Analytics</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
View detailed analytics and insights about your application usage.
|
||||
</p>
|
||||
<div className="h-32 bg-muted rounded-lg flex items-center justify-center">
|
||||
<span className="text-sm text-muted-foreground">Chart placeholder</span>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings" className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="app-name">Application Name</Label>
|
||||
<Input id="app-name" defaultValue="My App" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder="Enter description"
|
||||
/>
|
||||
</div>
|
||||
<Button>Save Settings</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Tabs with icons for visual enhancement",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ThreeTabs: Story = {
|
||||
render: () => (
|
||||
<Tabs defaultValue="tab1" className="w-96">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="tab1" className="flex-1">
|
||||
Tab 1
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tab2" className="flex-1">
|
||||
Tab 2
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tab3" className="flex-1">
|
||||
Tab 3
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tab1">
|
||||
<div className="p-4 border rounded-lg">
|
||||
<h4 className="font-semibold mb-2">First Tab Content</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is the content for the first tab. It can contain any elements.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="tab2">
|
||||
<div className="p-4 border rounded-lg">
|
||||
<h4 className="font-semibold mb-2">Second Tab Content</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is the content for the second tab with different information.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="tab3">
|
||||
<div className="p-4 border rounded-lg">
|
||||
<h4 className="font-semibold mb-2">Third Tab Content</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is the content for the third tab showing more details.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Three-tab layout with equal width distribution",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CompactTabs: Story = {
|
||||
render: () => (
|
||||
<Tabs defaultValue="general" className="w-80">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="general" className="text-xs">
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="text-xs">
|
||||
Security
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="billing" className="text-xs">
|
||||
Billing
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="team" className="text-xs">
|
||||
Team
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general" className="mt-3">
|
||||
<div className="text-sm space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">Profile Name</Label>
|
||||
<Input className="h-8 text-xs" defaultValue="John Doe" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Email</Label>
|
||||
<Input className="h-8 text-xs" type="email" defaultValue="john@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="security" className="mt-3">
|
||||
<div className="text-sm space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">Two-factor Authentication</Label>
|
||||
<div className="text-xs text-muted-foreground mt-1">Enabled</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Last Sign In</Label>
|
||||
<div className="text-xs text-muted-foreground mt-1">2 hours ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="billing" className="mt-3">
|
||||
<div className="text-sm space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">Current Plan</Label>
|
||||
<div className="text-xs text-muted-foreground mt-1">Pro ($19/month)</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Next Billing Date</Label>
|
||||
<div className="text-xs text-muted-foreground mt-1">January 15, 2024</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="team" className="mt-3">
|
||||
<div className="text-sm space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">Team Members</Label>
|
||||
<div className="text-xs text-muted-foreground mt-1">5 members</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Team Plan</Label>
|
||||
<div className="text-xs text-muted-foreground mt-1">Business</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Compact tabs with smaller sizing for dense interfaces",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledTab: Story = {
|
||||
render: () => (
|
||||
<Tabs defaultValue="available" className="w-96">
|
||||
<TabsList>
|
||||
<TabsTrigger value="available">Available</TabsTrigger>
|
||||
<TabsTrigger value="disabled" disabled>
|
||||
Disabled
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="coming-soon" disabled>
|
||||
Coming Soon
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="available" className="space-y-4">
|
||||
<h4 className="font-semibold">Available Content</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This tab is available and can be interacted with.
|
||||
</p>
|
||||
</TabsContent>
|
||||
<TabsContent value="disabled">
|
||||
<p className="text-sm text-muted-foreground">This content should not be accessible.</p>
|
||||
</TabsContent>
|
||||
<TabsContent value="coming-soon">
|
||||
<p className="text-sm text-muted-foreground">This content is coming soon.</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Tabs with disabled states for unavailable or restricted content",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,65 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import type * as React from "react";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium text-foreground transition-[color,box-shadow] focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:shadow-sm dark:text-muted-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium text-foreground transition-[color,box-shadow] focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:shadow-sm dark:text-muted-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
@@ -1,78 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { Timeline, TimelineItem } from "@popcorntime/ui/components/timeline";
|
||||
import { motion } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type TimelineSize = "sm" | "md" | "lg";
|
||||
export type TimelineStatus = "completed" | "in-progress" | "pending";
|
||||
export type TimelineColor =
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "muted"
|
||||
| "accent"
|
||||
| "destructive";
|
||||
export type TimelineColor = "primary" | "secondary" | "muted" | "accent" | "destructive";
|
||||
|
||||
interface TimelineElement {
|
||||
id: number;
|
||||
date: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: ReactNode | (() => ReactNode);
|
||||
status?: TimelineStatus;
|
||||
color?: TimelineColor;
|
||||
size?: TimelineSize;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
id: number;
|
||||
date: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: ReactNode | (() => ReactNode);
|
||||
status?: TimelineStatus;
|
||||
color?: TimelineColor;
|
||||
size?: TimelineSize;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TimelineLayoutProps {
|
||||
items: TimelineElement[];
|
||||
size?: "sm" | "md" | "lg";
|
||||
iconColor?: "primary" | "secondary" | "muted" | "accent";
|
||||
customIcon?: React.ReactNode;
|
||||
animate?: boolean;
|
||||
connectorColor?: "primary" | "secondary" | "muted" | "accent";
|
||||
className?: string;
|
||||
items: TimelineElement[];
|
||||
size?: "sm" | "md" | "lg";
|
||||
iconColor?: "primary" | "secondary" | "muted" | "accent";
|
||||
customIcon?: React.ReactNode;
|
||||
animate?: boolean;
|
||||
connectorColor?: "primary" | "secondary" | "muted" | "accent";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TimelineLayout = ({
|
||||
items,
|
||||
size = "md",
|
||||
iconColor,
|
||||
customIcon,
|
||||
animate = true,
|
||||
connectorColor,
|
||||
className,
|
||||
items,
|
||||
size = "md",
|
||||
iconColor,
|
||||
customIcon,
|
||||
animate = true,
|
||||
connectorColor,
|
||||
className,
|
||||
}: TimelineLayoutProps) => {
|
||||
return (
|
||||
<Timeline size={size} className={className}>
|
||||
{[...items].reverse().map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={animate ? { opacity: 0, y: 20 } : false}
|
||||
animate={animate ? { opacity: 1, y: 0 } : false}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: index * 0.1,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<TimelineItem
|
||||
date={item.date}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
icon={
|
||||
typeof item.icon === "function"
|
||||
? item.icon()
|
||||
: item.icon || customIcon
|
||||
}
|
||||
iconColor={item.color || iconColor}
|
||||
connectorColor={item.color || connectorColor}
|
||||
showConnector={index !== items.length - 1}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</Timeline>
|
||||
);
|
||||
return (
|
||||
<Timeline size={size} className={className}>
|
||||
{[...items].reverse().map((item, index) => (
|
||||
<motion.div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: false positive
|
||||
key={index}
|
||||
initial={animate ? { opacity: 0, y: 20 } : false}
|
||||
animate={animate ? { opacity: 1, y: 0 } : false}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: index * 0.1,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<TimelineItem
|
||||
date={item.date}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
icon={typeof item.icon === "function" ? item.icon() : item.icon || customIcon}
|
||||
iconColor={item.color || iconColor}
|
||||
connectorColor={item.color || connectorColor}
|
||||
showConnector={index !== items.length - 1}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</Timeline>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { motion, HTMLMotionProps } from "framer-motion";
|
||||
import { type HTMLMotionProps, motion } from "framer-motion";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
export type TimelineColor =
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "muted"
|
||||
| "accent"
|
||||
| "destructive";
|
||||
export type TimelineColor = "primary" | "secondary" | "muted" | "accent" | "destructive";
|
||||
|
||||
const timelineVariants = cva("flex flex-col relative", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: "gap-4",
|
||||
md: "gap-6",
|
||||
lg: "gap-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
sm: "gap-4",
|
||||
md: "gap-6",
|
||||
lg: "gap-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -33,10 +28,10 @@ const timelineVariants = cva("flex flex-col relative", {
|
||||
* @extends {VariantProps<typeof timelineVariants>}
|
||||
*/
|
||||
interface TimelineProps
|
||||
extends React.HTMLAttributes<HTMLOListElement>,
|
||||
VariantProps<typeof timelineVariants> {
|
||||
/** Size of the timeline icons */
|
||||
iconsize?: "sm" | "md" | "lg";
|
||||
extends React.HTMLAttributes<HTMLOListElement>,
|
||||
VariantProps<typeof timelineVariants> {
|
||||
/** Size of the timeline icons */
|
||||
iconsize?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,41 +39,37 @@ interface TimelineProps
|
||||
* @component
|
||||
*/
|
||||
const Timeline = React.forwardRef<HTMLOListElement, TimelineProps>(
|
||||
({ className, iconsize, size, children, ...props }, ref) => {
|
||||
const items = React.Children.toArray(children);
|
||||
({ className, iconsize, size, children, ...props }, ref) => {
|
||||
const items = React.Children.toArray(children);
|
||||
|
||||
if (items.length === 0) {
|
||||
return <TimelineEmpty />;
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return <TimelineEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ol
|
||||
ref={ref}
|
||||
aria-label="Timeline"
|
||||
className={cn(
|
||||
timelineVariants({ size }),
|
||||
"relative w-full mx-auto py-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{React.Children.map(children, (child, index) => {
|
||||
if (
|
||||
React.isValidElement(child) &&
|
||||
typeof child.type !== "string" &&
|
||||
"displayName" in child.type &&
|
||||
child.type.displayName === "TimelineItem"
|
||||
) {
|
||||
return React.cloneElement(child, {
|
||||
iconsize,
|
||||
showConnector: index !== items.length - 1,
|
||||
} as React.ComponentProps<typeof TimelineItem>);
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ol
|
||||
ref={ref}
|
||||
aria-label="Timeline"
|
||||
className={cn(timelineVariants({ size }), "relative w-full mx-auto py-8", className)}
|
||||
{...props}
|
||||
>
|
||||
{React.Children.map(children, (child, index) => {
|
||||
if (
|
||||
React.isValidElement(child) &&
|
||||
typeof child.type !== "string" &&
|
||||
"displayName" in child.type &&
|
||||
child.type.displayName === "TimelineItem"
|
||||
) {
|
||||
return React.cloneElement(child, {
|
||||
iconsize,
|
||||
showConnector: index !== items.length - 1,
|
||||
} as React.ComponentProps<typeof TimelineItem>);
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
);
|
||||
Timeline.displayName = "Timeline";
|
||||
|
||||
@@ -88,422 +79,374 @@ Timeline.displayName = "Timeline";
|
||||
* @extends {Omit<HTMLMotionProps<"li">, "ref">}
|
||||
*/
|
||||
interface TimelineItemProps extends Omit<HTMLMotionProps<"li">, "ref"> {
|
||||
/** Date string for the timeline item */
|
||||
date?: string;
|
||||
/** Title of the timeline item */
|
||||
title?: string;
|
||||
/** Description text */
|
||||
description?: string;
|
||||
/** Custom icon element */
|
||||
icon?: React.ReactNode;
|
||||
/** Color theme for the icon */
|
||||
iconColor?: TimelineColor;
|
||||
/** Current status of the item */
|
||||
status?: "completed" | "in-progress" | "pending";
|
||||
/** Color theme for the connector line */
|
||||
connectorColor?: TimelineColor;
|
||||
/** Whether to show the connector line */
|
||||
showConnector?: boolean;
|
||||
/** Size of the icon */
|
||||
iconsize?: "sm" | "md" | "lg";
|
||||
/** Loading state */
|
||||
loading?: boolean;
|
||||
/** Error message */
|
||||
error?: string;
|
||||
/** Date string for the timeline item */
|
||||
date?: string;
|
||||
/** Title of the timeline item */
|
||||
title?: string;
|
||||
/** Description text */
|
||||
description?: string;
|
||||
/** Custom icon element */
|
||||
icon?: React.ReactNode;
|
||||
/** Color theme for the icon */
|
||||
iconColor?: TimelineColor;
|
||||
/** Current status of the item */
|
||||
status?: "completed" | "in-progress" | "pending";
|
||||
/** Color theme for the connector line */
|
||||
connectorColor?: TimelineColor;
|
||||
/** Whether to show the connector line */
|
||||
showConnector?: boolean;
|
||||
/** Size of the icon */
|
||||
iconsize?: "sm" | "md" | "lg";
|
||||
/** Loading state */
|
||||
loading?: boolean;
|
||||
/** Error message */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const TimelineItem = React.forwardRef<HTMLLIElement, TimelineItemProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
date,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
iconColor,
|
||||
status = "completed",
|
||||
connectorColor,
|
||||
showConnector = true,
|
||||
iconsize,
|
||||
loading,
|
||||
error,
|
||||
// Omit unused Framer Motion props
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
initial,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
animate,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
transition,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const commonClassName = cn("relative w-full mb-8 last:mb-0", className);
|
||||
(
|
||||
{
|
||||
className,
|
||||
date,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
iconColor,
|
||||
status = "completed",
|
||||
connectorColor,
|
||||
showConnector = true,
|
||||
iconsize,
|
||||
loading,
|
||||
error,
|
||||
// Omit unused Framer Motion props
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
initial,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
animate,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
transition,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const commonClassName = cn("relative w-full mb-8 last:mb-0", className);
|
||||
|
||||
// Loading State
|
||||
if (loading) {
|
||||
return (
|
||||
<motion.li
|
||||
ref={ref}
|
||||
className={commonClassName}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
role="status"
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-cols-[minmax(auto,8rem)_auto_1fr] items-start px-4">
|
||||
<div className="pr-4 text-right">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
// Loading State
|
||||
if (loading) {
|
||||
return (
|
||||
<motion.li
|
||||
ref={ref}
|
||||
className={commonClassName}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
role="status"
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-cols-[minmax(auto,8rem)_auto_1fr] items-start px-4">
|
||||
<div className="pr-4 text-right">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
|
||||
<div className="mx-3 flex flex-col items-center justify-start gap-y-2">
|
||||
<div className="relative flex h-8 w-8 animate-pulse items-center justify-center rounded-full bg-muted ring-8 ring-background">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{showConnector && (
|
||||
<div className="h-full w-0.5 animate-pulse bg-muted" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-3 flex flex-col items-center justify-start gap-y-2">
|
||||
<div className="relative flex h-8 w-8 animate-pulse items-center justify-center rounded-full bg-muted ring-8 ring-background">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{showConnector && <div className="h-full w-0.5 animate-pulse bg-muted" />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pl-2">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-48 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
<div className="flex flex-col gap-2 pl-2">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-48 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
|
||||
// Error State
|
||||
if (error) {
|
||||
return (
|
||||
<motion.li
|
||||
ref={ref}
|
||||
className={cn(
|
||||
commonClassName,
|
||||
"border border-destructive/50 bg-destructive/10"
|
||||
)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
role="alert"
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-cols-[minmax(auto,8rem)_auto_1fr] items-start px-4">
|
||||
<div className="pr-4 text-right">
|
||||
<TimelineTime className="text-destructive">{date}</TimelineTime>
|
||||
</div>
|
||||
// Error State
|
||||
if (error) {
|
||||
return (
|
||||
<motion.li
|
||||
ref={ref}
|
||||
className={cn(commonClassName, "border border-destructive/50 bg-destructive/10")}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
role="alert"
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-cols-[minmax(auto,8rem)_auto_1fr] items-start px-4">
|
||||
<div className="pr-4 text-right">
|
||||
<TimelineTime className="text-destructive">{date}</TimelineTime>
|
||||
</div>
|
||||
|
||||
<div className="mx-3 flex flex-col items-center justify-start gap-y-2">
|
||||
<div className="relative flex h-8 w-8 items-center justify-center rounded-full bg-destructive/20 ring-8 ring-background">
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
</div>
|
||||
{showConnector && (
|
||||
<TimelineConnector status="pending" className="h-full" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-3 flex flex-col items-center justify-start gap-y-2">
|
||||
<div className="relative flex h-8 w-8 items-center justify-center rounded-full bg-destructive/20 ring-8 ring-background">
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
</div>
|
||||
{showConnector && <TimelineConnector status="pending" className="h-full" />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pl-2">
|
||||
<TimelineHeader>
|
||||
<TimelineTitle className="text-destructive">
|
||||
{title || "Error"}
|
||||
</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineDescription className="text-destructive">
|
||||
{error}
|
||||
</TimelineDescription>
|
||||
</div>
|
||||
</div>
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
<div className="flex flex-col gap-2 pl-2">
|
||||
<TimelineHeader>
|
||||
<TimelineTitle className="text-destructive">{title || "Error"}</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineDescription className="text-destructive">{error}</TimelineDescription>
|
||||
</div>
|
||||
</div>
|
||||
</motion.li>
|
||||
);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="grid grid-cols-[8rem_auto_1fr] gap-4 items-start"
|
||||
{...(status === "in-progress" ? { "aria-current": "step" } : {})}
|
||||
>
|
||||
{/* Date */}
|
||||
<div className="flex flex-col justify-start pt-1">
|
||||
<TimelineTime className="text-right pr-4 text-muted-foreground/90 dark:text-muted-foreground/50">
|
||||
{date}
|
||||
</TimelineTime>
|
||||
</div>
|
||||
const content = (
|
||||
<div
|
||||
className="grid grid-cols-[8rem_auto_1fr] gap-4 items-start"
|
||||
{...(status === "in-progress" ? { "aria-current": "step" } : {})}
|
||||
>
|
||||
{/* Date */}
|
||||
<div className="flex flex-col justify-start pt-1">
|
||||
<TimelineTime className="text-right pr-4 text-muted-foreground/90 dark:text-muted-foreground/50">
|
||||
{date}
|
||||
</TimelineTime>
|
||||
</div>
|
||||
|
||||
{/* Timeline dot and connector */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative z-10">
|
||||
<TimelineIcon
|
||||
icon={icon}
|
||||
color={iconColor}
|
||||
status={status}
|
||||
iconSize={iconsize}
|
||||
/>
|
||||
</div>
|
||||
{showConnector && <div className="h-16 w-0.5 bg-border mt-2" />}
|
||||
</div>
|
||||
{/* Timeline dot and connector */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative z-10">
|
||||
<TimelineIcon icon={icon} color={iconColor} status={status} iconSize={iconsize} />
|
||||
</div>
|
||||
{showConnector && <div className="h-16 w-0.5 bg-border mt-2" />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<TimelineContent>
|
||||
<TimelineHeader>
|
||||
<TimelineTitle className="text-muted-foreground/90 dark:text-muted-foreground/50 text-lg leading-7 font-bold select-none">
|
||||
{title}
|
||||
</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineDescription>{description}</TimelineDescription>
|
||||
</TimelineContent>
|
||||
</div>
|
||||
);
|
||||
{/* Content */}
|
||||
<TimelineContent>
|
||||
<TimelineHeader>
|
||||
<TimelineTitle className="text-muted-foreground/90 dark:text-muted-foreground/50 text-lg leading-7 font-bold select-none">
|
||||
{title}
|
||||
</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineDescription>{description}</TimelineDescription>
|
||||
</TimelineContent>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Filter out Framer Motion specific props
|
||||
const {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
style,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onDrag,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onDragStart,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onDragEnd,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onAnimationStart,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onAnimationComplete,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
transformTemplate,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
whileHover,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
whileTap,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
whileDrag,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
whileFocus,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
whileInView,
|
||||
...filteredProps
|
||||
} = props;
|
||||
// Filter out Framer Motion specific props
|
||||
const {
|
||||
// biome-ignore lint/correctness/noUnusedVariables: false positive
|
||||
style,
|
||||
// biome-ignore lint/correctness/noUnusedVariables: false positive
|
||||
onDrag,
|
||||
// biome-ignore lint/correctness/noUnusedVariables: false positive
|
||||
onDragStart,
|
||||
// biome-ignore lint/correctness/noUnusedVariables: false positive
|
||||
onDragEnd,
|
||||
// biome-ignore lint/correctness/noUnusedVariables: false positive
|
||||
onAnimationStart,
|
||||
// biome-ignore lint/correctness/noUnusedVariables: false positive
|
||||
onAnimationComplete,
|
||||
// biome-ignore lint/correctness/noUnusedVariables: false positive
|
||||
transformTemplate,
|
||||
// biome-ignore lint/correctness/noUnusedVariables: false positive
|
||||
whileHover,
|
||||
// biome-ignore lint/correctness/noUnusedVariables: false positive
|
||||
whileTap,
|
||||
// biome-ignore lint/correctness/noUnusedVariables: false positive
|
||||
whileDrag,
|
||||
// biome-ignore lint/correctness/noUnusedVariables: false positive
|
||||
whileFocus,
|
||||
// biome-ignore lint/correctness/noUnusedVariables: false positive
|
||||
whileInView,
|
||||
...filteredProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<li ref={ref} className={commonClassName} {...filteredProps}>
|
||||
{content}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li ref={ref} className={commonClassName} {...filteredProps}>
|
||||
{content}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
);
|
||||
TimelineItem.displayName = "TimelineItem";
|
||||
|
||||
interface TimelineTimeProps extends React.HTMLAttributes<HTMLTimeElement> {
|
||||
/** Date string, Date object, or timestamp */
|
||||
date?: string | Date | number;
|
||||
/** Optional format for displaying the date */
|
||||
format?: Intl.DateTimeFormatOptions;
|
||||
/** Date string, Date object, or timestamp */
|
||||
date?: string | Date | number;
|
||||
/** Optional format for displaying the date */
|
||||
format?: Intl.DateTimeFormatOptions;
|
||||
}
|
||||
|
||||
const defaultDateFormat: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
};
|
||||
|
||||
const TimelineTime = React.forwardRef<HTMLTimeElement, TimelineTimeProps>(
|
||||
({ className, date, format, children, ...props }, ref) => {
|
||||
const formattedDate = React.useMemo(() => {
|
||||
if (!date) return "";
|
||||
({ className, date, format, children, ...props }, ref) => {
|
||||
const formattedDate = React.useMemo(() => {
|
||||
if (!date) return "";
|
||||
|
||||
try {
|
||||
const dateObj = new Date(date);
|
||||
if (isNaN(dateObj.getTime())) return "";
|
||||
try {
|
||||
const dateObj = new Date(date);
|
||||
if (Number.isNaN(dateObj.getTime())) return "";
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
...defaultDateFormat,
|
||||
...format,
|
||||
}).format(dateObj);
|
||||
} catch (error) {
|
||||
console.error("Error formatting date:", error);
|
||||
return "";
|
||||
}
|
||||
}, [date, format]);
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
...defaultDateFormat,
|
||||
...format,
|
||||
}).format(dateObj);
|
||||
} catch (error) {
|
||||
console.error("Error formatting date:", error);
|
||||
return "";
|
||||
}
|
||||
}, [date, format]);
|
||||
|
||||
return (
|
||||
<time
|
||||
ref={ref}
|
||||
dateTime={date ? new Date(date).toISOString() : undefined}
|
||||
className={cn(
|
||||
"text-sm font-medium tracking-tight text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children || formattedDate}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<time
|
||||
ref={ref}
|
||||
dateTime={date ? new Date(date).toISOString() : undefined}
|
||||
className={cn("text-sm font-medium tracking-tight text-muted-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
{children || formattedDate}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
);
|
||||
TimelineTime.displayName = "TimelineTime";
|
||||
|
||||
const TimelineConnector = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
status?: "completed" | "in-progress" | "pending";
|
||||
color?: "primary" | "secondary" | "muted" | "accent";
|
||||
}
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
status?: "completed" | "in-progress" | "pending";
|
||||
color?: "primary" | "secondary" | "muted" | "accent";
|
||||
}
|
||||
>(({ className, status = "completed", color, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-0.5",
|
||||
{
|
||||
"bg-primary": color === "primary" || (!color && status === "completed"),
|
||||
"bg-muted": color === "muted" || (!color && status === "pending"),
|
||||
"bg-secondary": color === "secondary",
|
||||
"bg-accent": color === "accent",
|
||||
"bg-gradient-to-b from-primary to-muted":
|
||||
!color && status === "in-progress",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-0.5",
|
||||
{
|
||||
"bg-primary": color === "primary" || (!color && status === "completed"),
|
||||
"bg-muted": color === "muted" || (!color && status === "pending"),
|
||||
"bg-secondary": color === "secondary",
|
||||
"bg-accent": color === "accent",
|
||||
"bg-gradient-to-b from-primary to-muted": !color && status === "in-progress",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TimelineConnector.displayName = "TimelineConnector";
|
||||
|
||||
const TimelineHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center gap-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
const TimelineHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center gap-4", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TimelineHeader.displayName = "TimelineHeader";
|
||||
|
||||
const TimelineTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"font-semibold leading-none tracking-tight text-secondary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight text-secondary-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
));
|
||||
TimelineTitle.displayName = "TimelineTitle";
|
||||
|
||||
const TimelineIcon = ({
|
||||
icon,
|
||||
color = "primary",
|
||||
iconSize = "md",
|
||||
icon,
|
||||
color = "primary",
|
||||
iconSize = "md",
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
color?: "primary" | "secondary" | "muted" | "accent" | "destructive";
|
||||
status?: "completed" | "in-progress" | "pending" | "error";
|
||||
iconSize?: "sm" | "md" | "lg";
|
||||
icon?: React.ReactNode;
|
||||
color?: "primary" | "secondary" | "muted" | "accent" | "destructive";
|
||||
status?: "completed" | "in-progress" | "pending" | "error";
|
||||
iconSize?: "sm" | "md" | "lg";
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: "h-8 w-8",
|
||||
md: "h-10 w-10",
|
||||
lg: "h-12 w-12",
|
||||
};
|
||||
const sizeClasses = {
|
||||
sm: "h-8 w-8",
|
||||
md: "h-10 w-10",
|
||||
lg: "h-12 w-12",
|
||||
};
|
||||
|
||||
const iconSizeClasses = {
|
||||
sm: "h-4 w-4",
|
||||
md: "h-5 w-5",
|
||||
lg: "h-6 w-6",
|
||||
};
|
||||
const iconSizeClasses = {
|
||||
sm: "h-4 w-4",
|
||||
md: "h-5 w-5",
|
||||
lg: "h-6 w-6",
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
primary: "bg-primary text-primary-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
muted: "bg-muted text-muted-foreground",
|
||||
accent: "bg-accent text-accent-foreground",
|
||||
destructive: "bg-destructive text-destructive-foreground",
|
||||
};
|
||||
const colorClasses = {
|
||||
primary: "bg-primary text-primary-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
muted: "bg-muted text-muted-foreground",
|
||||
accent: "bg-accent text-accent-foreground",
|
||||
destructive: "bg-destructive text-destructive-foreground",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center justify-center rounded-full ring-8 ring-background shadow-sm",
|
||||
sizeClasses[iconSize],
|
||||
colorClasses[color]
|
||||
)}
|
||||
>
|
||||
{icon ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
iconSizeClasses[iconSize]
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("rounded-full", iconSizeClasses[iconSize])} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center justify-center rounded-full ring-8 ring-background shadow-sm",
|
||||
sizeClasses[iconSize],
|
||||
colorClasses[color]
|
||||
)}
|
||||
>
|
||||
{icon ? (
|
||||
<div className={cn("flex items-center justify-center", iconSizeClasses[iconSize])}>
|
||||
{icon}
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("rounded-full", iconSizeClasses[iconSize])} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TimelineDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("max-w-sm text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
<p ref={ref} className={cn("max-w-sm text-sm text-muted-foreground", className)} {...props} />
|
||||
));
|
||||
TimelineDescription.displayName = "TimelineDescription";
|
||||
|
||||
const TimelineContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col gap-2 pl-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
const TimelineContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col gap-2 pl-2", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TimelineContent.displayName = "TimelineContent";
|
||||
|
||||
const TimelineEmpty = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{children || "No timeline items to display"}
|
||||
</p>
|
||||
</div>
|
||||
));
|
||||
const TimelineEmpty = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col items-center justify-center p-8 text-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">{children || "No timeline items to display"}</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
TimelineEmpty.displayName = "TimelineEmpty";
|
||||
|
||||
export {
|
||||
Timeline,
|
||||
TimelineItem,
|
||||
TimelineConnector,
|
||||
TimelineHeader,
|
||||
TimelineTitle,
|
||||
TimelineIcon,
|
||||
TimelineDescription,
|
||||
TimelineContent,
|
||||
TimelineTime,
|
||||
TimelineEmpty,
|
||||
Timeline,
|
||||
TimelineItem,
|
||||
TimelineConnector,
|
||||
TimelineHeader,
|
||||
TimelineTitle,
|
||||
TimelineIcon,
|
||||
TimelineDescription,
|
||||
TimelineContent,
|
||||
TimelineTime,
|
||||
TimelineEmpty,
|
||||
};
|
||||
|
||||
358
packages/popcorntime-ui/src/components/toggle-group.stories.tsx
Normal file
358
packages/popcorntime-ui/src/components/toggle-group.stories.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Bold, Film, Grid, Italic, LayoutGrid, List, Star, Tv, Underline } from "lucide-react";
|
||||
import { fn } from "storybook/test";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@popcorntime/ui/components/toggle-group";
|
||||
|
||||
const meta = {
|
||||
title: "Components/ToggleGroup",
|
||||
component: ToggleGroup,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A set of two-state buttons that can either be pressed (on) or not pressed (off).",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
type: {
|
||||
control: { type: "select" },
|
||||
options: ["single", "multiple"],
|
||||
description: "Determines whether one or multiple items can be pressed at a time",
|
||||
},
|
||||
value: {
|
||||
control: "text",
|
||||
description: 'The controlled value of the pressed item when type is "single"',
|
||||
},
|
||||
defaultValue: {
|
||||
control: "text",
|
||||
description: "The value of the item to show as pressed when initially rendered",
|
||||
},
|
||||
onValueChange: {
|
||||
action: "onValueChange",
|
||||
description: "Event handler called when the pressed state of an item changes",
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
description: "Whether the toggle group is disabled",
|
||||
},
|
||||
orientation: {
|
||||
control: { type: "select" },
|
||||
options: ["horizontal", "vertical"],
|
||||
description: "The orientation of the component",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onValueChange: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof ToggleGroup>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
type: "multiple",
|
||||
},
|
||||
render: args => (
|
||||
<ToggleGroup {...args}>
|
||||
<ToggleGroupItem value="bold" aria-label="Toggle bold">
|
||||
<Bold className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic" aria-label="Toggle italic">
|
||||
<Italic className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="underline" aria-label="Toggle underline">
|
||||
<Underline className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
),
|
||||
};
|
||||
|
||||
export const Single: Story = {
|
||||
args: {
|
||||
type: "single",
|
||||
defaultValue: "movies",
|
||||
},
|
||||
render: args => (
|
||||
<ToggleGroup {...args}>
|
||||
<ToggleGroupItem value="movies" aria-label="Movies">
|
||||
<Film className="h-4 w-4 mr-2" />
|
||||
Movies
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="tv" aria-label="TV Shows">
|
||||
<Tv className="h-4 w-4 mr-2" />
|
||||
TV Shows
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="favorites" aria-label="Favorites">
|
||||
<Star className="h-4 w-4 mr-2" />
|
||||
Favorites
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Toggle group with single selection, like radio buttons",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Multiple: Story = {
|
||||
args: {
|
||||
type: "multiple",
|
||||
defaultValue: ["bold", "italic"],
|
||||
},
|
||||
render: args => (
|
||||
<ToggleGroup {...args}>
|
||||
<ToggleGroupItem value="bold" aria-label="Bold">
|
||||
<Bold className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic" aria-label="Italic">
|
||||
<Italic className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="underline" aria-label="Underline">
|
||||
<Underline className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Toggle group allowing multiple selections, like checkboxes",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ViewModes: Story = {
|
||||
args: {
|
||||
type: "single",
|
||||
defaultValue: "grid",
|
||||
},
|
||||
render: args => (
|
||||
<ToggleGroup {...args}>
|
||||
<ToggleGroupItem value="grid" aria-label="Grid view">
|
||||
<Grid className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="list" aria-label="List view">
|
||||
<List className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="card" aria-label="Card view">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Toggle group for switching between different view modes",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabels: Story = {
|
||||
args: {
|
||||
type: "single",
|
||||
defaultValue: "all",
|
||||
},
|
||||
render: args => (
|
||||
<ToggleGroup {...args}>
|
||||
<ToggleGroupItem value="all">All Content</ToggleGroupItem>
|
||||
<ToggleGroupItem value="movies">Movies</ToggleGroupItem>
|
||||
<ToggleGroupItem value="tv">TV Shows</ToggleGroupItem>
|
||||
<ToggleGroupItem value="documentaries">Documentaries</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Toggle group with text labels for content filtering",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const QualitySelector: Story = {
|
||||
args: {
|
||||
type: "single",
|
||||
defaultValue: "1080p",
|
||||
},
|
||||
render: args => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Video Quality</h3>
|
||||
<ToggleGroup {...args}>
|
||||
<ToggleGroupItem value="720p">720p</ToggleGroupItem>
|
||||
<ToggleGroupItem value="1080p">1080p</ToggleGroupItem>
|
||||
<ToggleGroupItem value="1440p">1440p</ToggleGroupItem>
|
||||
<ToggleGroupItem value="4k">4K</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Toggle group for selecting video quality in PopcornTime",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Filters: Story = {
|
||||
args: {
|
||||
type: "multiple",
|
||||
defaultValue: ["action", "comedy"],
|
||||
},
|
||||
render: args => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Genre Filters</h3>
|
||||
<ToggleGroup {...args}>
|
||||
<ToggleGroupItem value="action">Action</ToggleGroupItem>
|
||||
<ToggleGroupItem value="comedy">Comedy</ToggleGroupItem>
|
||||
<ToggleGroupItem value="drama">Drama</ToggleGroupItem>
|
||||
<ToggleGroupItem value="horror">Horror</ToggleGroupItem>
|
||||
<ToggleGroupItem value="sci-fi">Sci-Fi</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Multiple selection toggle group for filtering by genres",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Vertical: Story = {
|
||||
args: {
|
||||
type: "single",
|
||||
orientation: "vertical",
|
||||
defaultValue: "popular",
|
||||
},
|
||||
render: args => (
|
||||
<ToggleGroup {...args}>
|
||||
<ToggleGroupItem value="popular">Popular</ToggleGroupItem>
|
||||
<ToggleGroupItem value="trending">Trending</ToggleGroupItem>
|
||||
<ToggleGroupItem value="latest">Latest</ToggleGroupItem>
|
||||
<ToggleGroupItem value="top-rated">Top Rated</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Vertical orientation toggle group for sorting options",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
type: "multiple",
|
||||
disabled: true,
|
||||
},
|
||||
render: args => (
|
||||
<ToggleGroup {...args}>
|
||||
<ToggleGroupItem value="bold" aria-label="Bold">
|
||||
<Bold className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic" aria-label="Italic">
|
||||
<Italic className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="underline" aria-label="Underline">
|
||||
<Underline className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Disabled toggle group state",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SortingOptions: Story = {
|
||||
args: {
|
||||
type: "single",
|
||||
defaultValue: "date-desc",
|
||||
},
|
||||
render: args => (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Sort by:</label>
|
||||
<ToggleGroup {...args}>
|
||||
<ToggleGroupItem value="date-desc">Newest First</ToggleGroupItem>
|
||||
<ToggleGroupItem value="date-asc">Oldest First</ToggleGroupItem>
|
||||
<ToggleGroupItem value="rating">Rating</ToggleGroupItem>
|
||||
<ToggleGroupItem value="popularity">Popularity</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Toggle group for sorting media content",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const StreamingQuality: Story = {
|
||||
args: {
|
||||
type: "single",
|
||||
defaultValue: "auto",
|
||||
},
|
||||
render: args => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium">Streaming Quality</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Choose the video quality for streaming content
|
||||
</p>
|
||||
<ToggleGroup {...args} orientation="vertical">
|
||||
<ToggleGroupItem value="auto" className="justify-start">
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Auto</div>
|
||||
<div className="text-xs text-muted-foreground">Adjust based on connection</div>
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="720p" className="justify-start">
|
||||
<div className="text-left">
|
||||
<div className="font-medium">720p HD</div>
|
||||
<div className="text-xs text-muted-foreground">Good for slower connections</div>
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="1080p" className="justify-start">
|
||||
<div className="text-left">
|
||||
<div className="font-medium">1080p Full HD</div>
|
||||
<div className="text-xs text-muted-foreground">Best for most devices</div>
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="4k" className="justify-start">
|
||||
<div className="text-left">
|
||||
<div className="font-medium">4K Ultra HD</div>
|
||||
<div className="text-xs text-muted-foreground">Requires fast connection</div>
|
||||
</div>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Detailed toggle group for streaming quality settings with descriptions",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,72 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { toggleVariants } from "@popcorntime/ui/components/toggle";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
|
||||
241
packages/popcorntime-ui/src/components/toggle.stories.tsx
Normal file
241
packages/popcorntime-ui/src/components/toggle.stories.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { Toggle } from "@popcorntime/ui/components/toggle";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Toggle",
|
||||
component: Toggle,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A toggle button component with pressed/unpressed states, built with Radix UI Toggle primitives.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["default", "outline"],
|
||||
description: "The visual variant of the toggle",
|
||||
},
|
||||
size: {
|
||||
control: { type: "select" },
|
||||
options: ["default", "sm", "lg"],
|
||||
description: "The size of the toggle",
|
||||
},
|
||||
pressed: {
|
||||
control: "boolean",
|
||||
description: "Whether the toggle is pressed",
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
description: "Whether the toggle is disabled",
|
||||
},
|
||||
children: {
|
||||
control: "text",
|
||||
description: "Toggle content",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onPressedChange: fn(),
|
||||
children: "Toggle",
|
||||
},
|
||||
} satisfies Meta<typeof Toggle>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: "Toggle",
|
||||
},
|
||||
};
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<div className="flex gap-4 items-center">
|
||||
<Toggle variant="default">Default</Toggle>
|
||||
<Toggle variant="outline">Outline</Toggle>
|
||||
<Toggle variant="default" pressed>
|
||||
Default Pressed
|
||||
</Toggle>
|
||||
<Toggle variant="outline" pressed>
|
||||
Outline Pressed
|
||||
</Toggle>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Different toggle variants in both pressed and unpressed states",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex gap-4 items-center">
|
||||
<Toggle size="sm">Small</Toggle>
|
||||
<Toggle size="default">Default</Toggle>
|
||||
<Toggle size="lg">Large</Toggle>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Different toggle sizes",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: () => (
|
||||
<div className="flex gap-4 items-center">
|
||||
<Toggle>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
Favorite
|
||||
</Toggle>
|
||||
|
||||
<Toggle pressed>
|
||||
<svg className="w-4 h-4" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
Favorited
|
||||
</Toggle>
|
||||
|
||||
<Toggle size="sm" variant="outline">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
</Toggle>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Toggle buttons with icons, showing both text+icon and icon-only variants",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const IconOnly: Story = {
|
||||
render: () => (
|
||||
<div className="flex gap-4 items-center">
|
||||
<Toggle size="sm" aria-label="Bold">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 12h9m-9-6h9m-9 12h9"
|
||||
/>
|
||||
</svg>
|
||||
</Toggle>
|
||||
|
||||
<Toggle aria-label="Italic">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6L8 6 6 18l2 0"
|
||||
/>
|
||||
</svg>
|
||||
</Toggle>
|
||||
|
||||
<Toggle size="lg" aria-label="Underline">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 3v9a3 3 0 003 3h0a3 3 0 003-3V3M7 21h10"
|
||||
/>
|
||||
</svg>
|
||||
</Toggle>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Icon-only toggles with proper aria-labels for accessibility",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<div className="flex gap-4 items-center">
|
||||
<Toggle disabled>Disabled</Toggle>
|
||||
<Toggle disabled pressed>
|
||||
Disabled Pressed
|
||||
</Toggle>
|
||||
<Toggle variant="outline" disabled>
|
||||
Disabled Outline
|
||||
</Toggle>
|
||||
<Toggle variant="outline" disabled pressed>
|
||||
Disabled Outline Pressed
|
||||
</Toggle>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Disabled toggle states for different variants",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Toolbar: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center border border-border rounded-md p-1">
|
||||
<Toggle size="sm" aria-label="Bold">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z" />
|
||||
</svg>
|
||||
</Toggle>
|
||||
<Toggle size="sm" aria-label="Italic">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z" />
|
||||
</svg>
|
||||
</Toggle>
|
||||
<Toggle size="sm" aria-label="Underline">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z" />
|
||||
</svg>
|
||||
</Toggle>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Toggle buttons grouped together in a toolbar layout",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,46 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import type * as React from "react";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-secondary data-[state=on]:text-secondary-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-secondary data-[state=on]:text-secondary-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
|
||||
273
packages/popcorntime-ui/src/components/tooltip.stories.tsx
Normal file
273
packages/popcorntime-ui/src/components/tooltip.stories.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "@popcorntime/ui/components/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@popcorntime/ui/components/tooltip";
|
||||
|
||||
const meta = {
|
||||
title: "Components/Tooltip",
|
||||
component: Tooltip,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A tooltip component built with Radix UI primitives. Shows contextual information on hover or focus.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
Story => (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Story />
|
||||
</TooltipProvider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof Tooltip>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline">Hover me</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>This is a tooltip</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
};
|
||||
|
||||
export const Positions: Story = {
|
||||
render: () => (
|
||||
<div className="grid grid-cols-3 gap-8 place-items-center min-h-[300px]">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Top
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Tooltip on top</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Right
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Tooltip on right</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Bottom
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Tooltip on bottom</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Left
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Tooltip on left</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div></div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Auto
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Auto-positioned tooltip</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Tooltips positioned on different sides of the trigger element",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: () => (
|
||||
<div className="flex gap-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Information</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Add new item</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete item</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Icon buttons with tooltips providing action context",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline">Long tooltip</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>
|
||||
This is a longer tooltip with more detailed information that wraps to multiple lines when
|
||||
the content is too long for a single line.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Tooltip with longer content that wraps to multiple lines",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDelay: Story = {
|
||||
render: () => (
|
||||
<div className="flex gap-4">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
No delay
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Instant tooltip</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
500ms delay
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delayed tooltip</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={1000}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
1s delay
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Very delayed tooltip</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Tooltips with different delay durations",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Keyboard: Story = {
|
||||
render: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline">Focus me with Tab</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Accessible via keyboard focus</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Tooltip that can be triggered via keyboard focus for accessibility",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,59 +1,54 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import type * as React from "react";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[500] w-fit rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[500] w-fit rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
@@ -3,19 +3,17 @@ import * as React from "react";
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
return !!isMobile;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export function timeDisplay(totalMinutes: number | string) {
|
||||
const asMinutes = Number(totalMinutes);
|
||||
//const t = useTranslations('Time')
|
||||
const hours = Math.floor(asMinutes / 60);
|
||||
let minutes = asMinutes % 60;
|
||||
if (minutes < 5 || minutes > 55) {
|
||||
minutes = 0;
|
||||
}
|
||||
const asMinutes = Number(totalMinutes);
|
||||
//const t = useTranslations('Time')
|
||||
const hours = Math.floor(asMinutes / 60);
|
||||
let minutes = asMinutes % 60;
|
||||
if (minutes < 5 || minutes > 55) {
|
||||
minutes = 0;
|
||||
}
|
||||
|
||||
return `${hours}h ${minutes}m`;
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -1,180 +1,185 @@
|
||||
/* biome-ignore-all lint/suspicious/noUnknownAtRules: tailwindcss apply */
|
||||
|
||||
@import "tailwindcss";
|
||||
@source "../../../apps/**/*.{ts,tsx,css}";
|
||||
@source "../**/*.{ts,tsx}";
|
||||
|
||||
@font-face {
|
||||
font-family: "Geist";
|
||||
font-display: swap;
|
||||
src: url("../fonts/Geist-Variable.woff2") format("woff2");
|
||||
font-family: "Geist";
|
||||
font-display: swap;
|
||||
src: url("../fonts/Geist-Variable.woff2") format("woff2");
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--font-geist-sans: "Geist";
|
||||
--poster-w: 150px;
|
||||
--poster-h: 225px;
|
||||
--scanline-base: 0 0 0;
|
||||
--scanline-base2: 0.12 0 0;
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--font-geist-sans: "Geist";
|
||||
--poster-w: 150px;
|
||||
--poster-h: 225px;
|
||||
--scanline-base: 0 0 0;
|
||||
--scanline-base2: 0.12 0 0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--scanline-base: 1 0 0;
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--scanline-base: 1 0 0;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
html {
|
||||
@apply scroll-smooth;
|
||||
font-family: var(--font-geist-sans);
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground overscroll-none;
|
||||
font-synthesis-weight: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
html {
|
||||
@apply scroll-smooth;
|
||||
font-family: var(--font-geist-sans);
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground overscroll-none;
|
||||
font-synthesis-weight: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
}
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
@apply [scrollbar-width:none] [&::-webkit-scrollbar]:hidden;
|
||||
@apply [scrollbar-width:none] [&::-webkit-scrollbar]:hidden;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.poster {
|
||||
width: var(--poster-w);
|
||||
height: var(--poster-h);
|
||||
}
|
||||
.poster {
|
||||
width: var(--poster-w);
|
||||
height: var(--poster-h);
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant macos {
|
||||
&:where([data-platform="macos"] *) {
|
||||
@slot;
|
||||
}
|
||||
&:where([data-platform="macos"] *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant windows {
|
||||
&:where([data-platform="windows"] *) {
|
||||
@slot;
|
||||
}
|
||||
&:where([data-platform="windows"] *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant linux {
|
||||
&:where([data-platform="linux"] *) {
|
||||
@slot;
|
||||
}
|
||||
&:where([data-platform="linux"] *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant dark {
|
||||
&:where([data-theme="dark"] *) {
|
||||
@slot;
|
||||
}
|
||||
&:where([data-theme="dark"] *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0 }
|
||||
to { opacity: 1 }
|
||||
}
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.6s ease-out forwards;
|
||||
}
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.6s ease-out forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"extends": "@popcorntime/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@popcorntime/ui/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"extends": "@popcorntime/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@popcorntime/ui/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import path from "node:path";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@popcorntime/ui": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
plugins: [tailwindcss(), react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@popcorntime/ui": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
plugins: [tailwindcss(), react()],
|
||||
});
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"name": "@popcorntime/translator",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"bin": {
|
||||
"translate": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"translate": "tsx src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@popcorntime/typescript-config": "workspace:*",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/translate": "^9.2.0",
|
||||
"@popcorntime/i18n": "workspace:*",
|
||||
"@types/jsonpath": "^0.2.4",
|
||||
"dotenv": "^16.6.1",
|
||||
"jsonpath": "^1.1.1",
|
||||
"openai": "^4.104.0"
|
||||
}
|
||||
"name": "@popcorntime/translator",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"bin": {
|
||||
"translate": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"translate": "tsx src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@popcorntime/typescript-config": "workspace:*",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/translate": "^9.2.0",
|
||||
"@popcorntime/i18n": "workspace:*",
|
||||
"@types/jsonpath": "^0.2.4",
|
||||
"dotenv": "^16.6.1",
|
||||
"jsonpath": "^1.1.1",
|
||||
"openai": "^4.104.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,121 +1,102 @@
|
||||
import { TranslationServiceClient } from "@google-cloud/translate";
|
||||
import OpenAIApi from "openai";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { config } from "dotenv";
|
||||
import crypto from "crypto";
|
||||
import jsonpath from "jsonpath";
|
||||
import { locales as targetLanguages } from "@popcorntime/i18n";
|
||||
import { ChatCompletionMessageParam } from "openai/resources.mjs";
|
||||
import crypto from "crypto";
|
||||
import { config } from "dotenv";
|
||||
import fs from "fs";
|
||||
import jsonpath from "jsonpath";
|
||||
import OpenAIApi from "openai";
|
||||
import type { ChatCompletionMessageParam } from "openai/resources.mjs";
|
||||
import path from "path";
|
||||
|
||||
const __dirname = path.resolve();
|
||||
config({ path: path.join(__dirname, "../../.env") });
|
||||
|
||||
if (!process.env.GOOGLE_CLOUD_PROJECT_ID) {
|
||||
throw new Error("GOOGLE_CLOUD_PROJECT_ID is not set");
|
||||
throw new Error("GOOGLE_CLOUD_PROJECT_ID is not set");
|
||||
}
|
||||
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new Error("OPENAI_API_KEY is not set");
|
||||
throw new Error("OPENAI_API_KEY is not set");
|
||||
}
|
||||
|
||||
// use system credentials
|
||||
const translate = new TranslationServiceClient();
|
||||
|
||||
const openai = new OpenAIApi({
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
});
|
||||
|
||||
const TARGET_DIR = path.join(
|
||||
__dirname,
|
||||
"../../crates/popcorntime-tauri/dictionaries"
|
||||
);
|
||||
const TARGET_DIR = path.join(__dirname, "../../crates/popcorntime-tauri/dictionaries");
|
||||
|
||||
const lockFilePath = path.join(
|
||||
__dirname,
|
||||
"../../crates/popcorntime-tauri/dictionaries.lock"
|
||||
);
|
||||
const lockFilePath = path.join(__dirname, "../../crates/popcorntime-tauri/dictionaries.lock");
|
||||
const englishFilePath = path.join(TARGET_DIR, "en.json");
|
||||
|
||||
function generateHash(value: string) {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function loadLockFile() {
|
||||
if (fs.existsSync(lockFilePath)) {
|
||||
return JSON.parse(fs.readFileSync(lockFilePath, "utf-8"));
|
||||
}
|
||||
return {};
|
||||
if (fs.existsSync(lockFilePath)) {
|
||||
return JSON.parse(fs.readFileSync(lockFilePath, "utf-8"));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function saveLockFile(lock: Record<string, any>) {
|
||||
fs.writeFileSync(lockFilePath, JSON.stringify(lock, null, 2));
|
||||
fs.writeFileSync(lockFilePath, JSON.stringify(lock, null, 2));
|
||||
}
|
||||
|
||||
function markKey(
|
||||
lock: Record<string, any>,
|
||||
lang: string,
|
||||
jsonPath: string,
|
||||
hash: string
|
||||
) {
|
||||
if (!lock[lang]) lock[lang] = {};
|
||||
lock[lang][jsonPath] = hash;
|
||||
function markKey(lock: Record<string, any>, lang: string, jsonPath: string, hash: string) {
|
||||
if (!lock[lang]) lock[lang] = {};
|
||||
lock[lang][jsonPath] = hash;
|
||||
}
|
||||
|
||||
// Check if a key hash matches
|
||||
function hashMatches(
|
||||
lock: Record<string, any>,
|
||||
lang: string,
|
||||
jsonPath: string,
|
||||
hash: string
|
||||
) {
|
||||
return lock[lang]?.[jsonPath] === hash;
|
||||
function hashMatches(lock: Record<string, any>, lang: string, jsonPath: string, hash: string) {
|
||||
return lock[lang]?.[jsonPath] === hash;
|
||||
}
|
||||
|
||||
function detectChanges(
|
||||
original: Record<string, any>,
|
||||
updated: Record<string, any>,
|
||||
pathPrefix = "$"
|
||||
original: Record<string, any>,
|
||||
updated: Record<string, any>,
|
||||
pathPrefix = "$"
|
||||
) {
|
||||
const changes: { path: string; hash: string }[] = [];
|
||||
for (const key in updated) {
|
||||
const currentPath = `${pathPrefix}['${key}']`;
|
||||
const changes: { path: string; hash: string }[] = [];
|
||||
for (const key in updated) {
|
||||
const currentPath = `${pathPrefix}['${key}']`;
|
||||
|
||||
if (typeof updated[key] === "object" || Array.isArray(updated[key])) {
|
||||
const subChanges = detectChanges(
|
||||
original[key] || {},
|
||||
updated[key],
|
||||
currentPath
|
||||
);
|
||||
changes.push(...subChanges);
|
||||
} else {
|
||||
const currentHash = generateHash(updated[key]);
|
||||
const previousHash = original[key] ? generateHash(original[key]) : null;
|
||||
if (currentHash !== previousHash) {
|
||||
changes.push({ path: currentPath, hash: currentHash });
|
||||
}
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
if (typeof updated[key] === "object" || Array.isArray(updated[key])) {
|
||||
const subChanges = detectChanges(original[key] || {}, updated[key], currentPath);
|
||||
changes.push(...subChanges);
|
||||
} else {
|
||||
const currentHash = generateHash(updated[key]);
|
||||
const previousHash = original[key] ? generateHash(original[key]) : null;
|
||||
if (currentHash !== previousHash) {
|
||||
changes.push({ path: currentPath, hash: currentHash });
|
||||
}
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function googleTranslateText(text: string, targetLang: string) {
|
||||
return translate
|
||||
.translateText({
|
||||
sourceLanguageCode: "en",
|
||||
targetLanguageCode: targetLang,
|
||||
contents: [text],
|
||||
parent: `projects/${process.env.GOOGLE_CLOUD_PROJECT_ID}/locations/global`,
|
||||
})
|
||||
.then(([response]) => {
|
||||
return response.translations;
|
||||
});
|
||||
return translate
|
||||
.translateText({
|
||||
sourceLanguageCode: "en",
|
||||
targetLanguageCode: targetLang,
|
||||
contents: [text],
|
||||
parent: `projects/${process.env.GOOGLE_CLOUD_PROJECT_ID}/locations/global`,
|
||||
})
|
||||
.then(([response]) => {
|
||||
return response.translations;
|
||||
});
|
||||
}
|
||||
|
||||
async function gptTranslateWithContext(text: string, targetLang: string) {
|
||||
const messages = [
|
||||
{
|
||||
role: "system",
|
||||
content: `
|
||||
const messages = [
|
||||
{
|
||||
role: "system",
|
||||
content: `
|
||||
You are a professional translator creating high-quality translations for desktop application and website.
|
||||
We use 'next-intl' for our website and react-i18next for the desktop app as our translation library.
|
||||
If we provide you with a context, use it to translate the text.
|
||||
@@ -123,176 +104,170 @@ async function gptTranslateWithContext(text: string, targetLang: string) {
|
||||
If we use {platform} or {country} or {language}, keep it in the translation as well. (Single brace is for website and double brace is for desktop app)
|
||||
The translation is for the Popcorn Time web site and app, keep this is mind when you translate as it's related to movies and tv shows. Keep it cool and funny but stay professional.
|
||||
`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Translate the following text into ${targetLang}
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Translate the following text into ${targetLang}
|
||||
Text: ${text}
|
||||
|
||||
You should reply only the translated content. Nothing else as it'll be parsed.
|
||||
`,
|
||||
},
|
||||
] as ChatCompletionMessageParam[];
|
||||
},
|
||||
] as ChatCompletionMessageParam[];
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-4o",
|
||||
messages,
|
||||
max_tokens: 1000,
|
||||
temperature: 0.7,
|
||||
});
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-4o",
|
||||
messages,
|
||||
max_tokens: 1000,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return response.choices?.[0]?.message?.content?.trim();
|
||||
return response.choices?.[0]?.message?.content?.trim();
|
||||
}
|
||||
|
||||
async function translateJSON(filePath: string, targetLang: string) {
|
||||
const englishContent = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
const targetFilePath = path.join(TARGET_DIR, `${targetLang}.json`);
|
||||
let targetContent = {};
|
||||
const englishContent = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
const targetFilePath = path.join(TARGET_DIR, `${targetLang}.json`);
|
||||
let targetContent = {};
|
||||
|
||||
const lock = loadLockFile();
|
||||
const lock = loadLockFile();
|
||||
|
||||
// Load existing translations for the target language
|
||||
if (fs.existsSync(targetFilePath)) {
|
||||
targetContent = JSON.parse(fs.readFileSync(targetFilePath, "utf-8"));
|
||||
}
|
||||
// Load existing translations for the target language
|
||||
if (fs.existsSync(targetFilePath)) {
|
||||
targetContent = JSON.parse(fs.readFileSync(targetFilePath, "utf-8"));
|
||||
}
|
||||
|
||||
// Clean up unused keys
|
||||
const removedKeys = cleanUpUnusedKeys(englishContent, targetContent);
|
||||
if (removedKeys.length > 0) {
|
||||
console.log(`Removed ${removedKeys.length} unused keys for ${targetLang}`);
|
||||
}
|
||||
// Clean up unused keys
|
||||
const removedKeys = cleanUpUnusedKeys(englishContent, targetContent);
|
||||
if (removedKeys.length > 0) {
|
||||
console.log(`Removed ${removedKeys.length} unused keys for ${targetLang}`);
|
||||
}
|
||||
|
||||
const changedKeys = detectChanges(targetContent, englishContent);
|
||||
console.log(`Found ${changedKeys.length} keys for ${targetLang}`);
|
||||
for (const { path: jsonPath, hash } of changedKeys) {
|
||||
if (!hashMatches(lock, targetLang, jsonPath, hash)) {
|
||||
const value = jsonpath.value(englishContent, jsonPath);
|
||||
const changedKeys = detectChanges(targetContent, englishContent);
|
||||
console.log(`Found ${changedKeys.length} keys for ${targetLang}`);
|
||||
for (const { path: jsonPath, hash } of changedKeys) {
|
||||
if (!hashMatches(lock, targetLang, jsonPath, hash)) {
|
||||
const value = jsonpath.value(englishContent, jsonPath);
|
||||
|
||||
const useGoogleT =
|
||||
value.length <= 5 ||
|
||||
jsonPath.startsWith("$['genres']") ||
|
||||
jsonPath.startsWith("$['country']") ||
|
||||
jsonPath.startsWith("$['language']");
|
||||
const useGoogleT =
|
||||
value.length <= 5 ||
|
||||
jsonPath.startsWith("$['genres']") ||
|
||||
jsonPath.startsWith("$['country']") ||
|
||||
jsonPath.startsWith("$['language']");
|
||||
|
||||
let translation;
|
||||
if (!useGoogleT) {
|
||||
console.log(`Translating: ${jsonPath} with GPT`);
|
||||
translation = await gptTranslateWithContext(value, targetLang);
|
||||
} else {
|
||||
console.log(`Translating: ${jsonPath} with Google Translate`);
|
||||
const t = await googleTranslateText(value, targetLang);
|
||||
if (t && t.length > 0 && t[0]?.translatedText) {
|
||||
translation = t[0].translatedText;
|
||||
// Capitalize the first letter
|
||||
if (
|
||||
jsonPath.startsWith("$['genres']") ||
|
||||
jsonPath.startsWith("$['country']") ||
|
||||
jsonPath.startsWith("$['language']")
|
||||
) {
|
||||
translation = capitalize(translation);
|
||||
}
|
||||
}
|
||||
}
|
||||
let translation;
|
||||
if (!useGoogleT) {
|
||||
console.log(`Translating: ${jsonPath} with GPT`);
|
||||
translation = await gptTranslateWithContext(value, targetLang);
|
||||
} else {
|
||||
console.log(`Translating: ${jsonPath} with Google Translate`);
|
||||
const t = await googleTranslateText(value, targetLang);
|
||||
if (t && t.length > 0 && t[0]?.translatedText) {
|
||||
translation = t[0].translatedText;
|
||||
// Capitalize the first letter
|
||||
if (
|
||||
jsonPath.startsWith("$['genres']") ||
|
||||
jsonPath.startsWith("$['country']") ||
|
||||
jsonPath.startsWith("$['language']")
|
||||
) {
|
||||
translation = capitalize(translation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensureParentPathExists(targetContent, jsonPath);
|
||||
jsonpath.value(targetContent, jsonPath, translation);
|
||||
markKey(lock, targetLang, jsonPath, hash);
|
||||
}
|
||||
}
|
||||
ensureParentPathExists(targetContent, jsonPath);
|
||||
jsonpath.value(targetContent, jsonPath, translation);
|
||||
markKey(lock, targetLang, jsonPath, hash);
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TARGET_DIR, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(TARGET_DIR, targetLang + ".json"),
|
||||
JSON.stringify(ensureArrayStructure(englishContent, targetContent), null, 2)
|
||||
);
|
||||
saveLockFile(lock);
|
||||
console.log(`Translated ${filePath} to ${targetLang}`);
|
||||
fs.mkdirSync(TARGET_DIR, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(TARGET_DIR, targetLang + ".json"),
|
||||
JSON.stringify(ensureArrayStructure(englishContent, targetContent), null, 2)
|
||||
);
|
||||
saveLockFile(lock);
|
||||
console.log(`Translated ${filePath} to ${targetLang}`);
|
||||
}
|
||||
|
||||
function ensureParentPathExists(target: Record<string, any>, jsonPath: string) {
|
||||
const pathParts = jsonPath
|
||||
.replace(/^\$\['/, "")
|
||||
.replace(/'\]$/g, "")
|
||||
.split("']['");
|
||||
if (pathParts.length < 2) return;
|
||||
const pathParts = jsonPath
|
||||
.replace(/^\$\['/, "")
|
||||
.replace(/'\]$/g, "")
|
||||
.split("']['");
|
||||
if (pathParts.length < 2) return;
|
||||
|
||||
let current = target;
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const key = pathParts[i];
|
||||
if (
|
||||
(key && !(key in current)) ||
|
||||
(key && typeof current[key] !== "object")
|
||||
) {
|
||||
current[key] = {};
|
||||
}
|
||||
let current = target;
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const key = pathParts[i];
|
||||
if ((key && !(key in current)) || (key && typeof current[key] !== "object")) {
|
||||
current[key] = {};
|
||||
}
|
||||
|
||||
if (key) {
|
||||
current = current[key];
|
||||
}
|
||||
}
|
||||
if (key) {
|
||||
current = current[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanUpUnusedKeys(
|
||||
original: Record<string, any>,
|
||||
translated: Record<string, any>,
|
||||
pathPrefix = "$"
|
||||
original: Record<string, any>,
|
||||
translated: Record<string, any>,
|
||||
pathPrefix = "$"
|
||||
) {
|
||||
const keysToDelete = [];
|
||||
const keysToDelete = [];
|
||||
|
||||
for (const key in translated) {
|
||||
const currentPath = `${pathPrefix}['${key}']`;
|
||||
for (const key in translated) {
|
||||
const currentPath = `${pathPrefix}['${key}']`;
|
||||
|
||||
if (
|
||||
typeof translated[key] === "object" &&
|
||||
!Array.isArray(translated[key]) &&
|
||||
translated[key] !== null
|
||||
) {
|
||||
// Recursively clean nested objects
|
||||
cleanUpUnusedKeys(original[key] || {}, translated[key], currentPath);
|
||||
if (
|
||||
typeof translated[key] === "object" &&
|
||||
!Array.isArray(translated[key]) &&
|
||||
translated[key] !== null
|
||||
) {
|
||||
// Recursively clean nested objects
|
||||
cleanUpUnusedKeys(original[key] || {}, translated[key], currentPath);
|
||||
|
||||
// If the object becomes empty after cleaning, mark it for deletion
|
||||
if (Object.keys(translated[key]).length === 0) {
|
||||
delete translated[key];
|
||||
}
|
||||
} else if (!(key in original)) {
|
||||
// Key doesn't exist in the original, mark for deletion
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
}
|
||||
// If the object becomes empty after cleaning, mark it for deletion
|
||||
if (Object.keys(translated[key]).length === 0) {
|
||||
delete translated[key];
|
||||
}
|
||||
} else if (!(key in original)) {
|
||||
// Key doesn't exist in the original, mark for deletion
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete unused keys from the translated object
|
||||
keysToDelete.forEach((key) => {
|
||||
delete translated[key];
|
||||
});
|
||||
// Delete unused keys from the translated object
|
||||
keysToDelete.forEach(key => {
|
||||
delete translated[key];
|
||||
});
|
||||
|
||||
return translated;
|
||||
return translated;
|
||||
}
|
||||
|
||||
function ensureArrayStructure(
|
||||
original: Record<string, any>,
|
||||
updated: Record<string, any>
|
||||
) {
|
||||
for (const key in updated) {
|
||||
if (Array.isArray(updated[key])) {
|
||||
// Directly assign array, not object-like indices
|
||||
original[key] = updated[key];
|
||||
} else if (typeof updated[key] === "object" && updated[key] !== null) {
|
||||
// Recursively process objects
|
||||
original[key] = ensureArrayStructure(original[key] || {}, updated[key]);
|
||||
} else {
|
||||
// Otherwise just assign the value
|
||||
original[key] = updated[key];
|
||||
}
|
||||
}
|
||||
return original;
|
||||
function ensureArrayStructure(original: Record<string, any>, updated: Record<string, any>) {
|
||||
for (const key in updated) {
|
||||
if (Array.isArray(updated[key])) {
|
||||
// Directly assign array, not object-like indices
|
||||
original[key] = updated[key];
|
||||
} else if (typeof updated[key] === "object" && updated[key] !== null) {
|
||||
// Recursively process objects
|
||||
original[key] = ensureArrayStructure(original[key] || {}, updated[key]);
|
||||
} else {
|
||||
// Otherwise just assign the value
|
||||
original[key] = updated[key];
|
||||
}
|
||||
}
|
||||
return original;
|
||||
}
|
||||
|
||||
function capitalize(input: string | null | undefined) {
|
||||
if (!input) return input;
|
||||
return input.charAt(0).toUpperCase() + input.slice(1);
|
||||
if (!input) return input;
|
||||
return input.charAt(0).toUpperCase() + input.slice(1);
|
||||
}
|
||||
|
||||
for (const lang of targetLanguages) {
|
||||
console.log(`Translating to ${lang}...`);
|
||||
await translateJSON(englishFilePath, lang);
|
||||
console.log(`Translating to ${lang}...`);
|
||||
await translateJSON(englishFilePath, lang);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"extends": "@popcorntime/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@popcorntime/i18n/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"extends": "@popcorntime/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@popcorntime/i18n/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Default",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"incremental": false,
|
||||
"isolatedModules": true,
|
||||
"lib": ["es2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2022"
|
||||
}
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Default",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"incremental": false,
|
||||
"isolatedModules": true,
|
||||
"lib": ["es2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2022"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@popcorntime/typescript-config",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "PROPRIETARY",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
"name": "@popcorntime/typescript-config",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "PROPRIETARY",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "React Library",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "React Library",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
}
|
||||
}
|
||||
|
||||
3349
pnpm-lock.yaml
generated
3349
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user