chore: import @unom/ui from @played/ui
Copy of the played-ui component library, rebranded to the @unom scope for publishing as @unom/ui. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
storybook-static
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.stories.@(ts|tsx)"],
|
||||
addons: [],
|
||||
framework: {
|
||||
name: "@storybook/react-vite",
|
||||
options: {
|
||||
builder: { viteConfigPath: ".storybook/vite.storybook.config.ts" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,24 @@
|
||||
import "../src/styles/theme.css";
|
||||
import { definePreview } from "@storybook/react-vite";
|
||||
import { AnimationProvider } from "@/animation/provider";
|
||||
import Section from "@/section";
|
||||
import { SoundProvider } from "@/sound/provider";
|
||||
|
||||
export default definePreview({
|
||||
addons: [],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<AnimationProvider theme={{}}>
|
||||
<SoundProvider theme={{}}>
|
||||
<Section maxWidth={false}>
|
||||
<Story />
|
||||
</Section>
|
||||
</SoundProvider>
|
||||
</AnimationProvider>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
controls: { matchers: { color: /(background|color)$/i, date: /Date$/ } },
|
||||
layout: "fullscreen",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { resolve } from "node:path";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "../src"),
|
||||
},
|
||||
},
|
||||
plugins: [viteReact(), tailwindcss()],
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"name": "@unom/ui",
|
||||
"type": "module",
|
||||
"version": "0.8.16",
|
||||
"description": "ui elements for unom",
|
||||
"private": false,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"storybook": "storybook dev -p 6011",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.16",
|
||||
"@bosh-code/tsdown-plugin-inject-css": "^2.0.0",
|
||||
"@bosh-code/tsdown-plugin-tailwindcss": "^1.0.1",
|
||||
"@rollup/plugin-url": "^8.0.2",
|
||||
"@storybook/react-vite": "^10.4.2",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/bun": "latest",
|
||||
"@types/howler": "^2.2.13",
|
||||
"@types/react": "^19.2.16",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"storybook": "^10.4.2",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tsdown": "^0.22.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@payloadcms/richtext-lexical": "^3.85.0",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"@unom/style": "^0.4.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"motion": "^12.40.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"typescript": "^6.0.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"exports": {
|
||||
"./accordion": "./dist/accordion.js",
|
||||
"./animate-number": "./dist/animate-number.js",
|
||||
"./animation": "./dist/animation/index.js",
|
||||
"./button": "./dist/button.js",
|
||||
"./card": "./dist/card.js",
|
||||
"./card-context": "./dist/card-context.js",
|
||||
"./dialog": "./dist/dialog.js",
|
||||
"./elastic-slider": "./dist/elastic-slider.js",
|
||||
"./form/checkbox": "./dist/form/checkbox.js",
|
||||
"./form/field-container": "./dist/form/field-container.js",
|
||||
"./form/form": "./dist/form/form.js",
|
||||
"./form/input-number": "./dist/form/input-number.js",
|
||||
"./form/input-text": "./dist/form/input-text.js",
|
||||
"./form/label": "./dist/form/label.js",
|
||||
"./form/select": "./dist/form/select.js",
|
||||
"./form/textarea": "./dist/form/textarea.js",
|
||||
"./hooks/useControllableState": "./dist/hooks/useControllableState.js",
|
||||
"./lib/utils": "./dist/lib/utils.js",
|
||||
"./preload-reload": "./dist/preload-reload.js",
|
||||
"./sound": "./dist/sound/index.js",
|
||||
"./richtext": "./dist/richtext/index.js",
|
||||
"./richtext/converters": "./dist/richtext/converters/index.js",
|
||||
"./richtext/converters/headings": "./dist/richtext/converters/headings.js",
|
||||
"./richtext/converters/internal-link": "./dist/richtext/converters/internal-link.js",
|
||||
"./section": "./dist/section/index.js",
|
||||
"./section/heading": "./dist/section/heading/index.js",
|
||||
"./section/section.types": "./dist/section/section.types.js",
|
||||
"./styles/animations": "./dist/styles/animations.js",
|
||||
"./styles/material.css": "./dist/material.css",
|
||||
"./material": "./dist/material/index.js",
|
||||
"./tabs": "./dist/tabs.js",
|
||||
"./text": "./dist/text.js",
|
||||
"./toast": "./dist/toast.js",
|
||||
"./toggle-group": "./dist/toggle-group.js",
|
||||
"./virtualized-grid": "./dist/virtualized-grid.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.170.11",
|
||||
"@tsdown/css": "^0.22.1",
|
||||
"clsx": "^2.1.1",
|
||||
"howler": "^2.2.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"inlinedDependencies": {
|
||||
"framer-motion": "12.38.0",
|
||||
"motion-dom": "12.38.0",
|
||||
"motion-utils": "12.36.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"local>played/workflows:renovate-config"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "./accordion";
|
||||
|
||||
const meta: Meta<typeof Accordion> = {
|
||||
title: "accordion/Accordion",
|
||||
component: Accordion,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Accordion>;
|
||||
|
||||
export const Single: Story = {
|
||||
render: () => (
|
||||
<Accordion type="single" collapsible defaultValue="item-1">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Is it accessible?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes. It adheres to the WAI-ARIA design pattern.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Is it animated?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Yes — it uses the @unom/ui accordion animation tokens.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Can I open many at once?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Switch <code>type</code> to <code>"multiple"</code>.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
};
|
||||
|
||||
export const Multiple: Story = {
|
||||
render: () => (
|
||||
<Accordion type="multiple" defaultValue={["a", "b"]}>
|
||||
<AccordionItem value="a">
|
||||
<AccordionTrigger>First</AccordionTrigger>
|
||||
<AccordionContent>First content.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="b">
|
||||
<AccordionTrigger>Second</AccordionTrigger>
|
||||
<AccordionContent>Second content.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="c">
|
||||
<AccordionTrigger>Third</AccordionTrigger>
|
||||
<AccordionContent>Third content.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn(" last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between \
|
||||
gap-4 rounded-md py-4 text-left text-sm font-medium outline-none hover:underline \
|
||||
focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { Variants } from "motion";
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
type Transition,
|
||||
useReducedMotion,
|
||||
} from "motion/react";
|
||||
import { type FC, useEffect, useMemo, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Group = "prefix" | "integer" | "fraction" | "suffix";
|
||||
type Direction = "up" | "down";
|
||||
|
||||
type Slot = {
|
||||
slotKey: string;
|
||||
group: Group;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type AnimateNumberProps = {
|
||||
/** The numeric value to display. When it changes, the slot whose character
|
||||
* changed animates in the direction matching whether the overall value
|
||||
* went up or down. */
|
||||
children: number;
|
||||
|
||||
/** Options passed to `Intl.NumberFormat` as the second arg. Controls
|
||||
* currency, units, grouping, fraction digits, compact notation, etc. */
|
||||
format?: Intl.NumberFormatOptions;
|
||||
/** Locale(s) passed to `Intl.NumberFormat` as the first arg. Defaults to
|
||||
* the host locale. */
|
||||
locales?: Intl.LocalesArgument;
|
||||
/** Motion transition applied to every per-character slide + fade. */
|
||||
transition?: Transition;
|
||||
|
||||
className?: string;
|
||||
/** Extra classes applied to leading non-numeric chars (currency, signs). */
|
||||
prefixClassName?: string;
|
||||
/** Extra classes applied to integer-side digits and group separators. */
|
||||
integerClassName?: string;
|
||||
/** Extra classes applied to the decimal separator and fraction digits. */
|
||||
fractionClassName?: string;
|
||||
/** Extra classes applied to trailing non-numeric chars (percent, units). */
|
||||
suffixClassName?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_TRANSITION: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 320,
|
||||
damping: 28,
|
||||
mass: 0.6,
|
||||
};
|
||||
|
||||
// up = value went up → digits roll DOWN (new enters from top, old exits bottom)
|
||||
// down = value went down → digits roll UP (new enters from bottom, old exits top)
|
||||
const SLOT_VARIANTS: Variants = {
|
||||
enter: (dir: Direction) => ({
|
||||
y: dir === "up" ? "-100%" : "100%",
|
||||
opacity: 0,
|
||||
}),
|
||||
visible: { y: 0, opacity: 1 },
|
||||
exit: (dir: Direction) => ({
|
||||
y: dir === "up" ? "100%" : "-100%",
|
||||
opacity: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
// Outer wrapper variants — used when a whole slot is added (e.g. 99 → 100) or
|
||||
// removed (e.g. 100 → 99). Subtle fade so the digit-count change isn't a snap.
|
||||
const WRAPPER_VARIANTS: Variants = {
|
||||
enter: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
};
|
||||
|
||||
const partition = (
|
||||
value: number,
|
||||
locales: Intl.LocalesArgument | undefined,
|
||||
format: Intl.NumberFormatOptions | undefined,
|
||||
): Slot[] => {
|
||||
const parts = new Intl.NumberFormat(locales, format).formatToParts(value);
|
||||
|
||||
const isIntChar = (t: Intl.NumberFormatPart["type"]) =>
|
||||
t === "integer" || t === "group";
|
||||
const isFracChar = (t: Intl.NumberFormatPart["type"]) =>
|
||||
t === "decimal" || t === "fraction";
|
||||
|
||||
const firstNumericIdx = parts.findIndex(
|
||||
(p) => isIntChar(p.type) || isFracChar(p.type),
|
||||
);
|
||||
let lastNumericIdx = -1;
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const p = parts[i];
|
||||
if (!p) continue;
|
||||
if (isIntChar(p.type) || isFracChar(p.type)) {
|
||||
lastNumericIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let intCharCount = 0;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i];
|
||||
if (!p) continue;
|
||||
if (isIntChar(p.type)) intCharCount += p.value.length;
|
||||
else if (isFracChar(p.type)) break;
|
||||
}
|
||||
|
||||
const out: Slot[] = [];
|
||||
let intPosFromStart = 0;
|
||||
let fracPosFromStart = 0;
|
||||
let prefixSeq = 0;
|
||||
let suffixSeq = 0;
|
||||
|
||||
parts.forEach((p, i) => {
|
||||
const beforeNumeric = i < firstNumericIdx;
|
||||
const afterNumeric = i > lastNumericIdx;
|
||||
|
||||
if (beforeNumeric) {
|
||||
for (const ch of p.value) {
|
||||
out.push({
|
||||
slotKey: `pre-${prefixSeq++}-${p.type}`,
|
||||
value: ch,
|
||||
group: "prefix",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (afterNumeric) {
|
||||
for (const ch of p.value) {
|
||||
out.push({
|
||||
slotKey: `suf-${suffixSeq++}-${p.type}`,
|
||||
value: ch,
|
||||
group: "suffix",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isIntChar(p.type)) {
|
||||
for (const ch of p.value) {
|
||||
const posFromEnd = intCharCount - 1 - intPosFromStart;
|
||||
out.push({
|
||||
slotKey: `int-${posFromEnd}`,
|
||||
value: ch,
|
||||
group: "integer",
|
||||
});
|
||||
intPosFromStart += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.type === "decimal") {
|
||||
out.push({ slotKey: "decimal", value: p.value, group: "fraction" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.type === "fraction") {
|
||||
for (const ch of p.value) {
|
||||
out.push({
|
||||
slotKey: `frac-${fracPosFromStart}`,
|
||||
value: ch,
|
||||
group: "fraction",
|
||||
});
|
||||
fracPosFromStart += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
const groupClassName = (
|
||||
group: Group,
|
||||
prefix?: string,
|
||||
integer?: string,
|
||||
fraction?: string,
|
||||
suffix?: string,
|
||||
) => {
|
||||
switch (group) {
|
||||
case "prefix":
|
||||
return prefix;
|
||||
case "integer":
|
||||
return integer;
|
||||
case "fraction":
|
||||
return fraction;
|
||||
case "suffix":
|
||||
return suffix;
|
||||
}
|
||||
};
|
||||
|
||||
interface SlotViewProps {
|
||||
slot: Slot;
|
||||
direction: Direction;
|
||||
transition: Transition;
|
||||
groupClass?: string;
|
||||
}
|
||||
|
||||
const SlotView: FC<SlotViewProps> = ({
|
||||
slot,
|
||||
direction,
|
||||
transition,
|
||||
groupClass,
|
||||
}) => {
|
||||
// Bump a tick on every value change so each transition uses a fresh React
|
||||
// key. Without this, a value that re-appears before the prior exit finishes
|
||||
// would reuse the still-exiting motion node (visual glitch on fast sweeps).
|
||||
const tickRef = useRef(0);
|
||||
const prevValueRef = useRef<string | null>(null);
|
||||
if (prevValueRef.current !== slot.value) {
|
||||
tickRef.current += 1;
|
||||
prevValueRef.current = slot.value;
|
||||
}
|
||||
|
||||
return (
|
||||
// No `layout` prop here on purpose: when the parent (e.g. a slider's
|
||||
// popover) is transforming via motion values, motion's layout system
|
||||
// would re-measure each slot's viewport rect every frame and spring
|
||||
// toward the new position, which lags the parent transform and looks
|
||||
// like the digits drift in the slide direction. Slot adds/removes
|
||||
// (e.g. 99 → 100) snap instead of animating position — acceptable.
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
variants={WRAPPER_VARIANTS}
|
||||
initial="enter"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={transition}
|
||||
className="relative inline-block overflow-hidden align-baseline leading-[1em]"
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
|
||||
<motion.span
|
||||
key={tickRef.current}
|
||||
custom={direction}
|
||||
variants={SLOT_VARIANTS}
|
||||
initial="enter"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={transition}
|
||||
className={cn("inline-block", groupClass)}
|
||||
>
|
||||
{slot.value === " " ? " " : slot.value}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</motion.span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Animates between numeric values by sliding each character into place. Uses
|
||||
* `Intl.NumberFormat` for locale-aware formatting (currency, units, grouping,
|
||||
* compact notation, etc.).
|
||||
*
|
||||
* Stable digits stay put across renders; only the slots whose character
|
||||
* actually changed animate. When the overall value increases, digits roll
|
||||
* down (new enters from above); when it decreases, digits roll up.
|
||||
*
|
||||
* For composite displays like `mm:ss`, compose multiple `AnimateNumber`
|
||||
* instances with literal separators between them.
|
||||
*/
|
||||
export const AnimateNumber: FC<AnimateNumberProps> = ({
|
||||
children,
|
||||
format,
|
||||
locales,
|
||||
transition = DEFAULT_TRANSITION,
|
||||
className,
|
||||
prefixClassName,
|
||||
integerClassName,
|
||||
fractionClassName,
|
||||
suffixClassName,
|
||||
}) => {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const slots = useMemo(
|
||||
() => partition(children, locales, format),
|
||||
[children, locales, format],
|
||||
);
|
||||
const fullFormatted = useMemo(
|
||||
() => new Intl.NumberFormat(locales, format).format(children),
|
||||
[children, locales, format],
|
||||
);
|
||||
|
||||
// Compare to previous render's value to derive scroll direction. Updated
|
||||
// in an effect so Strict Mode's double-render doesn't desync the ref.
|
||||
const prevValueRef = useRef(children);
|
||||
const direction: Direction =
|
||||
children >= prevValueRef.current ? "up" : "down";
|
||||
useEffect(() => {
|
||||
prevValueRef.current = children;
|
||||
}, [children]);
|
||||
|
||||
if (shouldReduceMotion) {
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex items-baseline tabular-nums", className)}
|
||||
>
|
||||
{fullFormatted}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-baseline tabular-nums relative",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">{fullFormatted}</span>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{slots.map((slot) => (
|
||||
<SlotView
|
||||
key={slot.slotKey}
|
||||
slot={slot}
|
||||
direction={direction}
|
||||
transition={transition}
|
||||
groupClass={groupClassName(
|
||||
slot.group,
|
||||
prefixClassName,
|
||||
integerClassName,
|
||||
fractionClassName,
|
||||
suffixClassName,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ease } from "@unom/style";
|
||||
import { stagger } from "motion/react";
|
||||
import type { AnimationTheme } from "./types";
|
||||
|
||||
export const defaultAnimationTheme: AnimationTheme = {
|
||||
button: {
|
||||
variants: {
|
||||
enter: { scale: 1, opacity: 1 },
|
||||
from: { scale: 0, opacity: 0 },
|
||||
exit: { scale: 0.8, opacity: 0 },
|
||||
},
|
||||
whileHover: { scale: 1.05, opacity: 1 },
|
||||
whileTap: { scale: 0.95, opacity: 1 },
|
||||
responsiveHover: { minScale: 1.01, maxScale: 1.05, referenceWidth: 80 },
|
||||
},
|
||||
card: {
|
||||
variants: {
|
||||
enter: { scale: 1, opacity: 1 },
|
||||
from: { scale: 0.8, opacity: 0 },
|
||||
},
|
||||
transition: ease.quint(0.8).out,
|
||||
},
|
||||
checkbox: {
|
||||
variants: {
|
||||
enter: { scale: 1, opacity: 1 },
|
||||
from: { scale: 0, opacity: 0 },
|
||||
},
|
||||
whileHover: { scale: 1.1 },
|
||||
whileTap: { scale: 0.9 },
|
||||
transition: ease.quint(0.6).out,
|
||||
},
|
||||
text: {
|
||||
variants: {
|
||||
enter: { y: 0, opacity: 1 },
|
||||
from: { y: 10, opacity: 0 },
|
||||
},
|
||||
transition: ease.quint(0.8).out,
|
||||
},
|
||||
fieldContainer: {
|
||||
variants: {
|
||||
enter: { opacity: 1, y: 0 },
|
||||
from: { opacity: 0, y: -20 },
|
||||
},
|
||||
transition: ease.quint(0.9).out,
|
||||
},
|
||||
headingIcon: {
|
||||
variants: {
|
||||
enter: { y: 20, scale: 1, x: -30, rotate: -15 },
|
||||
from: { y: 100, scale: 0, x: 0, rotate: 0 },
|
||||
},
|
||||
transition: ease.quint(2.5).out,
|
||||
},
|
||||
section: {
|
||||
transition: { delayChildren: stagger(0.1) },
|
||||
variants: {
|
||||
container: {
|
||||
enter: {},
|
||||
from: {},
|
||||
exit: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
dialog: {},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export { defaultAnimationTheme } from "./defaults";
|
||||
export { mergeTheme } from "./merge";
|
||||
export {
|
||||
AnimationProvider,
|
||||
type AnimationProviderProps,
|
||||
useAnimation,
|
||||
useAnimationTheme,
|
||||
} from "./provider";
|
||||
export type {
|
||||
AnimationTheme,
|
||||
AnimationToken,
|
||||
PartialAnimationTheme,
|
||||
ResponsiveHoverConfig,
|
||||
SectionAnimationToken,
|
||||
} from "./types";
|
||||
export { useResponsiveHover } from "./use-responsive-hover";
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AnimationTheme, PartialAnimationTheme } from "./types";
|
||||
|
||||
function setMerged<K extends keyof AnimationTheme>(
|
||||
out: AnimationTheme,
|
||||
key: K,
|
||||
base: AnimationTheme,
|
||||
partial: Partial<AnimationTheme[K]>,
|
||||
) {
|
||||
// Shallow per-token merge: token fields replace wholesale.
|
||||
// Deep-merging variants/transitions would create surprising hybrids.
|
||||
out[key] = { ...base[key], ...partial };
|
||||
}
|
||||
|
||||
export function mergeTheme(
|
||||
base: AnimationTheme,
|
||||
override: PartialAnimationTheme,
|
||||
): AnimationTheme {
|
||||
const out = { ...base } as AnimationTheme;
|
||||
for (const key of Object.keys(override) as (keyof AnimationTheme)[]) {
|
||||
const partial = override[key];
|
||||
if (!partial) continue;
|
||||
setMerged(out, key, base, partial);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { defaultAnimationTheme } from "./defaults";
|
||||
import { mergeTheme } from "./merge";
|
||||
import type { AnimationTheme, PartialAnimationTheme } from "./types";
|
||||
|
||||
const AnimationContext = createContext<AnimationTheme>(defaultAnimationTheme);
|
||||
|
||||
export type AnimationProviderProps = {
|
||||
theme: PartialAnimationTheme;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function AnimationProvider({
|
||||
theme,
|
||||
children,
|
||||
}: AnimationProviderProps) {
|
||||
const parent = useContext(AnimationContext);
|
||||
const merged = useMemo(() => mergeTheme(parent, theme), [parent, theme]);
|
||||
return (
|
||||
<AnimationContext.Provider value={merged}>
|
||||
{children}
|
||||
</AnimationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAnimation<K extends keyof AnimationTheme>(
|
||||
key: K,
|
||||
): AnimationTheme[K] {
|
||||
return useContext(AnimationContext)[key];
|
||||
}
|
||||
|
||||
export function useAnimationTheme(): AnimationTheme {
|
||||
return useContext(AnimationContext);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { TargetAndTransition, Transition, Variants } from "motion/react";
|
||||
|
||||
export type ResponsiveHoverConfig = {
|
||||
/** Smallest scale delta, applied at large widths. e.g. 1.01 means at most +1%. */
|
||||
minScale?: number;
|
||||
/** Largest scale delta, applied at the reference width. e.g. 1.05 means at most +5%. */
|
||||
maxScale?: number;
|
||||
/** Width (px) at which `maxScale` applies. Above this, scale falls off. */
|
||||
referenceWidth?: number;
|
||||
};
|
||||
|
||||
export type AnimationToken = {
|
||||
variants?: Variants;
|
||||
transition?: Transition;
|
||||
whileHover?: TargetAndTransition;
|
||||
whileTap?: TargetAndTransition;
|
||||
/** If present, `whileHover.scale` is replaced with a width-derived value at runtime. */
|
||||
responsiveHover?: ResponsiveHoverConfig;
|
||||
};
|
||||
|
||||
export type SectionAnimationToken = {
|
||||
transition?: Transition;
|
||||
variants?: { container: Variants };
|
||||
};
|
||||
|
||||
export type AnimationTheme = {
|
||||
button: AnimationToken;
|
||||
card: AnimationToken;
|
||||
checkbox: AnimationToken;
|
||||
text: AnimationToken;
|
||||
fieldContainer: AnimationToken;
|
||||
headingIcon: AnimationToken;
|
||||
section: SectionAnimationToken;
|
||||
dialog: AnimationToken;
|
||||
};
|
||||
|
||||
export type PartialAnimationTheme = {
|
||||
[K in keyof AnimationTheme]?: Partial<AnimationTheme[K]>;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import type { ResponsiveHoverConfig } from "./types";
|
||||
|
||||
export function useResponsiveHover<T extends HTMLElement = HTMLElement>(
|
||||
config: ResponsiveHoverConfig | undefined,
|
||||
) {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [scale, setScale] = useState<number>(config?.maxScale ?? 1.05);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!config || !node || typeof ResizeObserver === "undefined") return;
|
||||
const {
|
||||
minScale = 1.01,
|
||||
maxScale = 1.05,
|
||||
referenceWidth = 80,
|
||||
} = config;
|
||||
const maxDelta = maxScale - 1;
|
||||
const minDelta = minScale - 1;
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
if (!entry) return;
|
||||
const width = entry.contentRect.width || referenceWidth;
|
||||
const delta = (referenceWidth / width) * maxDelta;
|
||||
const clamped = Math.min(Math.max(delta, minDelta), maxDelta);
|
||||
setScale(1 + clamped);
|
||||
});
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [config?.minScale, config?.maxScale, config?.referenceWidth]);
|
||||
|
||||
return { ref, scale };
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Heart } from "lucide-react";
|
||||
import { AnimatedButton, Button } from "./button";
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: "button/Button",
|
||||
component: Button,
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "destructive", "outline", "secondary", "ghost", "link"],
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["default", "sm", "lg", "icon"],
|
||||
},
|
||||
disabled: { control: "boolean" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Default: Story = { args: { children: "Click me" } };
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: { children: "Delete", variant: "destructive" },
|
||||
};
|
||||
|
||||
export const Outline: Story = {
|
||||
args: { children: "Outline", variant: "outline" },
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: { children: "Secondary", variant: "secondary" },
|
||||
};
|
||||
|
||||
export const Ghost: Story = { args: { children: "Ghost", variant: "ghost" } };
|
||||
|
||||
export const Link: Story = { args: { children: "Link", variant: "link" } };
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<Heart /> Favorite
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="default">Default</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
<Button size="icon" aria-label="Heart">
|
||||
<Heart />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Animated: StoryObj<typeof AnimatedButton> = {
|
||||
render: (args) => <AnimatedButton {...args}>Animated</AnimatedButton>,
|
||||
};
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { type MotionProps, motion } from "motion/react";
|
||||
import { Slot } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import type { ComponentProps, CSSProperties, FC } from "react";
|
||||
import { useAnimation } from "@/animation/provider";
|
||||
import { useResponsiveHover } from "@/animation/use-responsive-hover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMaterial } from "@/material/provider";
|
||||
import type { MaterialPreset } from "@/material/types";
|
||||
import { isWebkit, useSpecular } from "@/material/use-specular";
|
||||
import { useSound } from "@/sound/provider";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center no-underline justify-center gap-2 whitespace-nowrap rounded-button text-sm font-medium transition-color disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none 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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-neutral dark:text-main dark:text-neutral shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-error text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent/40 hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-primary/25 text-secondary-foreground shadow-xs hover:bg-primary/50 ",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
success:
|
||||
"bg-success text-main dark:text-neutral shadow-xs hover:bg-success/90",
|
||||
accent:
|
||||
"bg-accent text-main dark:text-neutral shadow-xs hover:bg-accent/90",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 font-medium text-md px-6 has-[>svg]:px-4",
|
||||
input: "h-input-height px-4 py-2 has-[>svg]:px-3",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Default variant → material preset mapping. `solid` = full glossy fill,
|
||||
* `neutral` = subtle gloss that keeps the border/ring, `none` = no material.
|
||||
* Override per-game via the MaterialProvider token (`variants`/`preset`).
|
||||
*/
|
||||
const BUTTON_PRESETS: Record<string, MaterialPreset> = {
|
||||
default: "solid",
|
||||
destructive: "solid",
|
||||
secondary: "neutral",
|
||||
outline: "neutral",
|
||||
ghost: "none",
|
||||
link: "none",
|
||||
success: "solid",
|
||||
accent: "solid",
|
||||
};
|
||||
|
||||
/** Solid-preset variants whose `--mat-base` isn't `--primary`. */
|
||||
const SOLID_MAT_BASE: Record<string, string> = {
|
||||
destructive: "var(--error)",
|
||||
success: "var(--success)",
|
||||
accent: "var(--accent)",
|
||||
};
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onPointerMove,
|
||||
onPointerLeave,
|
||||
asChild = false,
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
const { play: playClick } = useSound("click");
|
||||
const { play: playHover } = useSound("hover");
|
||||
|
||||
const material = useMaterial("button");
|
||||
const v = variant ?? "default";
|
||||
const preset: MaterialPreset =
|
||||
material.preset ?? material.variants?.[v] ?? BUTTON_PRESETS[v] ?? "solid";
|
||||
// Slot (asChild) expects a single child, so material (which adds the fx layer)
|
||||
// only applies to the real <button>.
|
||||
const active = !!material.enabled && !asChild && preset !== "none";
|
||||
// Don't pointer-track on WebKit: moving `.material-spot` inside the blurred
|
||||
// fx layer forces Safari to re-rasterise the blur on the CPU every mouse move
|
||||
// (a big hover-perf hit) and also trips the live-blur repaint artifact. Safari
|
||||
// gets a static spot — same as cards. Chromium/Firefox still track.
|
||||
const track = active && !isWebkit() && (material.trackPointer ?? true);
|
||||
const spec = useSpecular(track);
|
||||
|
||||
const matBase =
|
||||
preset === "neutral"
|
||||
? "var(--neutral)"
|
||||
: (SOLID_MAT_BASE[v] ?? "var(--primary)");
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-material={active ? preset : undefined}
|
||||
className={cn(
|
||||
buttonVariants({ variant, size }),
|
||||
active && "material relative",
|
||||
active && preset === "neutral" && "text-main dark:text-main",
|
||||
// Consumer className LAST so its utilities win conflicts (e.g. a
|
||||
// `position` override is not clobbered by the material's `relative`).
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
active
|
||||
? ({
|
||||
"--mat-base": matBase,
|
||||
"--mat-radius": "var(--radius-button)",
|
||||
...style,
|
||||
} as CSSProperties)
|
||||
: style
|
||||
}
|
||||
onClick={(e) => {
|
||||
onClick?.(e);
|
||||
playClick();
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
playHover();
|
||||
onMouseEnter?.(e);
|
||||
}}
|
||||
onPointerMove={
|
||||
track
|
||||
? (e) => {
|
||||
onPointerMove?.(e);
|
||||
spec.onPointerMove(e);
|
||||
}
|
||||
: onPointerMove
|
||||
}
|
||||
onPointerLeave={
|
||||
track
|
||||
? (e) => {
|
||||
onPointerLeave?.(e);
|
||||
spec.onPointerLeave(e);
|
||||
}
|
||||
: onPointerLeave
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{active ? (
|
||||
<>
|
||||
<span className="material-fx" aria-hidden="true">
|
||||
<i className="material-sheen" />
|
||||
<i className="material-spot" />
|
||||
</span>
|
||||
{children}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
const MButton = motion.create(Button);
|
||||
|
||||
const AnimatedButton: FC<ComponentProps<typeof Button> & MotionProps> = ({
|
||||
variants,
|
||||
transition,
|
||||
whileHover,
|
||||
whileTap,
|
||||
...props
|
||||
}) => {
|
||||
const token = useAnimation("button");
|
||||
const { ref, scale } = useResponsiveHover<HTMLButtonElement>(
|
||||
token.responsiveHover,
|
||||
);
|
||||
|
||||
const resolvedHover =
|
||||
whileHover ??
|
||||
(token.responsiveHover ? { ...token.whileHover, scale } : token.whileHover);
|
||||
|
||||
return (
|
||||
<MButton
|
||||
variants={variants ?? token.variants}
|
||||
transition={transition ?? token.transition}
|
||||
whileHover={resolvedHover}
|
||||
whileTap={whileTap ?? token.whileTap}
|
||||
{...(token.responsiveHover && !props.ref ? { ref } : {})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { AnimatedButton, Button, buttonVariants };
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const CardDepthContext = createContext(0);
|
||||
|
||||
export function useCardDepth() {
|
||||
return useContext(CardDepthContext);
|
||||
}
|
||||
|
||||
/** The depth-aware card border-radius for the CURRENT nesting level. Cards use
|
||||
* this for themselves; non-card elements that sit flush inside a card (media,
|
||||
* dropzones, previews) can call it to get the radius a nested card would have —
|
||||
* i.e. one level below the containing card, so they flush like a nested card. */
|
||||
export function useCardRadius(): string {
|
||||
const depth = useCardDepth();
|
||||
return `max(var(--radius-card-min, 0rem), calc(var(--radius-card) - ${depth} * var(--spacing-padding-card)))`;
|
||||
}
|
||||
|
||||
export function CardDepthProvider({ children }: { children: React.ReactNode }) {
|
||||
const depth = useCardDepth();
|
||||
return (
|
||||
<CardDepthContext.Provider value={depth + 1}>
|
||||
{children}
|
||||
</CardDepthContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import Card from "./card";
|
||||
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: "card/Card",
|
||||
component: Card,
|
||||
argTypes: {
|
||||
padding: { control: "boolean" },
|
||||
interactive: { control: "boolean" },
|
||||
muted: { control: "boolean" },
|
||||
colorVariant: { control: "select", options: ["default", "accent"] },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Card>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<h3>Card title</h3>
|
||||
<p>Some content inside a card.</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
interactive: true,
|
||||
children: (
|
||||
<>
|
||||
<h3>Hover me</h3>
|
||||
<p>This card highlights on hover.</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const Accent: Story = {
|
||||
args: {
|
||||
colorVariant: "accent",
|
||||
children: (
|
||||
<>
|
||||
<h3>Accent card</h3>
|
||||
<p>Uses the neutral-accent background.</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const Muted: Story = {
|
||||
args: {
|
||||
muted: true,
|
||||
children: (
|
||||
<>
|
||||
<h3>Muted ring</h3>
|
||||
<p>Lower-emphasis variant.</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const Nested: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<h3>Outer</h3>
|
||||
<Card>
|
||||
<h4>Inner</h4>
|
||||
<p>Border radius shrinks at deeper nesting.</p>
|
||||
<Card>
|
||||
<h5>Inner-inner</h5>
|
||||
</Card>
|
||||
</Card>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { type HTMLMotionProps, motion } from "motion/react";
|
||||
import { Slot } from "radix-ui";
|
||||
import {
|
||||
type CSSProperties,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useAnimation } from "@/animation/provider";
|
||||
import { useResponsiveHover } from "@/animation/use-responsive-hover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMaterial } from "@/material/provider";
|
||||
import { CardDepthProvider, useCardRadius } from "./card-context";
|
||||
|
||||
const cardVariants = cva(
|
||||
`flex flex-col gap-2 ring-2 rounded-card
|
||||
transition-colors grow justify-start
|
||||
|
||||
`,
|
||||
{
|
||||
variants: {
|
||||
padding: {
|
||||
true: "p-padding-card",
|
||||
false: "",
|
||||
},
|
||||
interactive: {
|
||||
false: "",
|
||||
true: "hover:bg-accent/30 cursor-pointer",
|
||||
},
|
||||
colorVariant: {
|
||||
default: "bg-neutral",
|
||||
accent: "bg-neutral-accent",
|
||||
},
|
||||
muted: {
|
||||
false: "ring-accent",
|
||||
true: "ring-accent/50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
interactive: false,
|
||||
padding: true,
|
||||
muted: false,
|
||||
colorVariant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type CommonCardProps = {
|
||||
asChild?: boolean;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
style?: HTMLAttributes<unknown>["style"];
|
||||
} & VariantProps<typeof cardVariants>;
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, CommonCardProps>(
|
||||
(
|
||||
{
|
||||
asChild,
|
||||
className,
|
||||
interactive,
|
||||
colorVariant,
|
||||
padding,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const material = useMaterial("card");
|
||||
|
||||
const Comp = asChild ? Slot.Root : "div";
|
||||
|
||||
// Inner-most cards "flush" with their parent by subtracting the card
|
||||
// padding per nesting level from the outer radius (see useCardRadius);
|
||||
// `--radius-card-min` stops it collapsing to zero past 2–3 levels.
|
||||
const radius = useCardRadius();
|
||||
|
||||
// Slot (asChild) expects a single child; the fx layer would break it, so
|
||||
// material only applies to the real <div>. Cards use a STATIC specular
|
||||
// (no pointer tracking) — calmer on large surfaces and sidesteps the
|
||||
// WebKit live-blur repaint bug entirely.
|
||||
const active = !!material.enabled && !asChild;
|
||||
|
||||
return (
|
||||
<CardDepthProvider>
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-material={active ? "neutral" : undefined}
|
||||
style={
|
||||
{
|
||||
borderRadius: radius,
|
||||
// Expose the card's depth-aware radius ALWAYS (not only when the
|
||||
// material is on) so flush children — media, dropzones — can match
|
||||
// it via var(--mat-radius). Without this, games that don't mount a
|
||||
// MaterialProvider fall back to the full --radius-card and overhang.
|
||||
"--mat-radius": radius,
|
||||
...(active
|
||||
? {
|
||||
"--mat-base":
|
||||
colorVariant === "accent"
|
||||
? "var(--neutral-accent)"
|
||||
: "var(--neutral)",
|
||||
}
|
||||
: null),
|
||||
...style,
|
||||
} as CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
cardVariants({ interactive, padding, colorVariant }),
|
||||
active &&
|
||||
"material material-card relative text-main dark:text-main",
|
||||
// Consumer className LAST so its utilities win conflicts — notably
|
||||
// `position` (e.g. a `sticky` card must keep sticky, not be
|
||||
// overridden by the material's `relative`; sticky still establishes
|
||||
// the containing block the fx layer needs).
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{active ? (
|
||||
<>
|
||||
<span className="material-fx" aria-hidden="true">
|
||||
<i className="material-sheen" />
|
||||
<i className="material-spot" />
|
||||
</span>
|
||||
{children}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Comp>
|
||||
</CardDepthProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Card.displayName = "Card";
|
||||
|
||||
const MCard = motion.create(Card);
|
||||
|
||||
export const AnimatedCard: React.FC<
|
||||
CommonCardProps & HTMLMotionProps<"div">
|
||||
> = ({ variants, transition, whileHover, whileTap, ...props }) => {
|
||||
const token = useAnimation("card");
|
||||
const { ref, scale } = useResponsiveHover<HTMLDivElement>(
|
||||
token.responsiveHover,
|
||||
);
|
||||
|
||||
const resolvedHover =
|
||||
whileHover ??
|
||||
(token.responsiveHover ? { ...token.whileHover, scale } : token.whileHover);
|
||||
|
||||
return (
|
||||
<MCard
|
||||
variants={variants ?? token.variants}
|
||||
// Preserve existing prop-merge semantics: prop transitions
|
||||
// spread on top of the token transition.
|
||||
transition={{ ...token.transition, ...transition }}
|
||||
whileHover={resolvedHover}
|
||||
whileTap={whileTap ?? token.whileTap}
|
||||
{...(token.responsiveHover && !props.ref ? { ref } : {})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
|
||||
export { cardVariants };
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { type HTMLMotionProps, motion } from "motion/react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { useAnimation } from "@/animation/provider";
|
||||
import { Button } from "@/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMaterial } from "@/material/provider";
|
||||
|
||||
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 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 DialogOverlay({
|
||||
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-100 bg-black/50 backdrop-blur-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const mat = useMaterial("dialog");
|
||||
const active = !!mat.enabled;
|
||||
return (
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
data-material={active ? "neutral" : undefined}
|
||||
style={
|
||||
active
|
||||
? ({
|
||||
"--mat-base": "var(--neutral)",
|
||||
"--mat-radius": "var(--radius-card)",
|
||||
...style,
|
||||
} as React.CSSProperties)
|
||||
: style
|
||||
}
|
||||
className={cn(
|
||||
"rounded-card text-main",
|
||||
// Always paint an opaque panel; the material layers on top when enabled.
|
||||
// Without the non-material fallback the modal is transparent in games
|
||||
// that don't mount a MaterialProvider.
|
||||
active
|
||||
? "material relative"
|
||||
: "border border-main/15 bg-neutral shadow-xl",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{active && (
|
||||
<span className="material-fx" aria-hidden="true">
|
||||
<i className="material-sheen" />
|
||||
<i className="material-spot" />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
const MDialogContent = motion.create(DialogContent);
|
||||
|
||||
type AnimatedDialogContentProps = React.ComponentProps<typeof DialogContent> &
|
||||
HTMLMotionProps<"div">;
|
||||
|
||||
function AnimatedDialogContent({
|
||||
variants,
|
||||
transition,
|
||||
whileHover,
|
||||
whileTap,
|
||||
...props
|
||||
}: AnimatedDialogContentProps) {
|
||||
const token = useAnimation("dialog");
|
||||
return (
|
||||
<MDialogContent
|
||||
variants={variants ?? token.variants}
|
||||
transition={transition ?? token.transition}
|
||||
whileHover={whileHover ?? token.whileHover}
|
||||
whileTap={whileTap ?? token.whileTap}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AnimatedDialogContent,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,584 @@
|
||||
import {
|
||||
animate,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useTransform,
|
||||
} from "motion/react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { AnimateNumber } from "@/animate-number";
|
||||
import { useControllableState } from "@/hooks/useControllableState";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMaterial } from "@/material/provider";
|
||||
|
||||
// Drag detection & rubber band
|
||||
const CLICK_THRESHOLD = 3;
|
||||
const DEAD_ZONE = 32;
|
||||
const MAX_CURSOR_RANGE = 200;
|
||||
const MAX_STRETCH = 8;
|
||||
|
||||
// Layout offsets used by the "handle dodges label/value" calculation.
|
||||
const HANDLE_BUFFER = 8;
|
||||
const LABEL_OFFSET = 12 + 4;
|
||||
const VALUE_OFFSET = 12 - 8;
|
||||
|
||||
function clamp(v: number, lo: number, hi: number) {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
function decimalsForStep(step: number): number {
|
||||
const s = step.toString();
|
||||
const dot = s.indexOf(".");
|
||||
return dot === -1 ? 0 : s.length - dot - 1;
|
||||
}
|
||||
|
||||
function roundValue(val: number, step: number): number {
|
||||
const raw = Math.round(val / step) * step;
|
||||
return parseFloat(raw.toFixed(decimalsForStep(step)));
|
||||
}
|
||||
|
||||
// Magnetic snap to the nearest decile when within 3.125% of it.
|
||||
function snapToDecile(rawValue: number, min: number, max: number): number {
|
||||
const normalized = (rawValue - min) / (max - min);
|
||||
const nearest = Math.round(normalized * 10) / 10;
|
||||
if (Math.abs(normalized - nearest) <= 0.03125) {
|
||||
return min + nearest * (max - min);
|
||||
}
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
export type ElasticSliderProps = {
|
||||
/** Label shown inside the track. */
|
||||
label: string;
|
||||
|
||||
/** Controlled value. Use together with `onValueChange` */
|
||||
value?: number;
|
||||
/** Initial value for uncontrolled mode. Falls back to `min` */
|
||||
defaultValue?: number;
|
||||
/** Called with the new value on drag, click, or key press. */
|
||||
onValueChange?: (value: number) => void;
|
||||
|
||||
/**
|
||||
* Minimum value.
|
||||
* @defaultValue 0 */
|
||||
min?: number;
|
||||
/**
|
||||
* Maximum value.
|
||||
* @defaultValue 1 */
|
||||
max?: number;
|
||||
/**
|
||||
* Smallest increment.
|
||||
* @defaultValue 0.01 */
|
||||
step?: number;
|
||||
/** Format the displayed value. Defaults to `value.toFixed(...)` based on `step` */
|
||||
formatValue?: (value: number) => string;
|
||||
|
||||
className?: string;
|
||||
/** Accessible name. Falls back to `label` */
|
||||
"aria-label"?: string;
|
||||
};
|
||||
|
||||
export function ElasticSlider({
|
||||
label,
|
||||
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
|
||||
min = 0,
|
||||
max = 1,
|
||||
step = 0.01,
|
||||
formatValue,
|
||||
|
||||
className,
|
||||
"aria-label": ariaLabel,
|
||||
}: ElasticSliderProps) {
|
||||
const mat = useMaterial("input");
|
||||
const [value = min, setValue] = useControllableState({
|
||||
prop: valueProp,
|
||||
defaultProp: defaultValue ?? min,
|
||||
onChange: onValueChange,
|
||||
});
|
||||
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const labelRef = useRef<HTMLSpanElement>(null);
|
||||
const valueRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const [isInteracting, setIsInteracting] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
/** Ring only for Tab focus or keyboard value nudges, not pointer press/drag. */
|
||||
const [keyboardFocusRing, setKeyboardFocusRing] = useState(false);
|
||||
|
||||
// Pointer session state — mutable, does not trigger re-renders.
|
||||
const pointerDownPos = useRef<{ x: number; y: number } | null>(null);
|
||||
const pendingPointerFocusRef = useRef(false);
|
||||
const isClickRef = useRef(true);
|
||||
const animRef = useRef<ReturnType<typeof animate> | null>(null);
|
||||
const wrapperRectRef = useRef<DOMRect | null>(null);
|
||||
const scaleRef = useRef(1);
|
||||
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
const isActive = isInteracting || isHovered;
|
||||
const decimals = decimalsForStep(step);
|
||||
const displayValue = formatValue
|
||||
? formatValue(value)
|
||||
: value.toFixed(decimals);
|
||||
const animatedFormat = useMemo<Intl.NumberFormatOptions>(
|
||||
() => ({
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
useGrouping: false,
|
||||
}),
|
||||
[decimals],
|
||||
);
|
||||
|
||||
// Fill + handle driven by a single motion value for imperative updates.
|
||||
const fillPercent = useMotionValue(percentage);
|
||||
const fillWidth = useTransform(fillPercent, (pct) => `${pct}%`);
|
||||
const handleLeft = useTransform(
|
||||
fillPercent,
|
||||
(pct) => `max(4px, calc(${pct}% - 8px))`,
|
||||
);
|
||||
|
||||
// Rubber band: widens the track and pulls it left when dragged past bounds.
|
||||
const rubberStretch = useMotionValue(0);
|
||||
const rubberWidth = useTransform(
|
||||
rubberStretch,
|
||||
(s) => `calc(100% + ${Math.abs(s)}px)`,
|
||||
);
|
||||
const rubberX = useTransform(rubberStretch, (s) => (s < 0 ? s : 0));
|
||||
|
||||
// Sync from props when not interacting and no spring is in flight.
|
||||
useEffect(() => {
|
||||
if (!isInteracting && !animRef.current) {
|
||||
fillPercent.jump(percentage);
|
||||
}
|
||||
}, [percentage, isInteracting, fillPercent]);
|
||||
|
||||
const positionToValue = useCallback(
|
||||
(clientX: number) => {
|
||||
const rect = wrapperRectRef.current;
|
||||
if (!rect) return min;
|
||||
|
||||
const sceneX = (clientX - rect.left) / scaleRef.current;
|
||||
const nativeWidth = wrapperRef.current?.offsetWidth ?? rect.width;
|
||||
const percent = clamp(sceneX / nativeWidth, 0, 1);
|
||||
|
||||
return clamp(min + percent * (max - min), min, max);
|
||||
},
|
||||
[min, max],
|
||||
);
|
||||
|
||||
const percentFromValue = useCallback(
|
||||
(v: number) => ((v - min) / (max - min)) * 100,
|
||||
[min, max],
|
||||
);
|
||||
|
||||
// Animate fill to a target percent, or jump instantly when the user prefers
|
||||
// reduced motion. Position still updates — only the spring is skipped.
|
||||
const animateFillTo = useCallback(
|
||||
(targetPercent: number) => {
|
||||
animRef.current?.stop();
|
||||
|
||||
if (shouldReduceMotion) {
|
||||
fillPercent.jump(targetPercent);
|
||||
animRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
animRef.current = animate(fillPercent, targetPercent, {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
mass: 0.8,
|
||||
onComplete: () => {
|
||||
animRef.current = null;
|
||||
},
|
||||
});
|
||||
},
|
||||
[fillPercent, shouldReduceMotion],
|
||||
);
|
||||
|
||||
const computeRubberStretch = useCallback((clientX: number, sign: number) => {
|
||||
const rect = wrapperRectRef.current;
|
||||
if (!rect) return 0;
|
||||
|
||||
const distancePast = sign < 0 ? rect.left - clientX : clientX - rect.right;
|
||||
const overflow = Math.max(0, distancePast - DEAD_ZONE);
|
||||
|
||||
return (
|
||||
sign * MAX_STRETCH * Math.sqrt(Math.min(overflow / MAX_CURSOR_RANGE, 1))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
|
||||
pointerDownPos.current = { x: e.clientX, y: e.clientY };
|
||||
|
||||
isClickRef.current = true;
|
||||
|
||||
setIsInteracting(true);
|
||||
|
||||
pendingPointerFocusRef.current = true;
|
||||
setKeyboardFocusRing(false);
|
||||
|
||||
// Pointer interactions should move focus to the slider so subsequent
|
||||
// keyboard input is received and focus styles match the active state.
|
||||
trackRef.current?.focus({ preventScroll: true });
|
||||
requestAnimationFrame(() => {
|
||||
pendingPointerFocusRef.current = false;
|
||||
});
|
||||
|
||||
// Snapshot the wrapper rect so later math is immune to layout shifts.
|
||||
const wrapper = wrapperRef.current;
|
||||
if (wrapper) {
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
wrapperRectRef.current = rect;
|
||||
scaleRef.current = rect.width / wrapper.offsetWidth;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isInteracting || !pointerDownPos.current) return;
|
||||
|
||||
const dx = e.clientX - pointerDownPos.current.x;
|
||||
const dy = e.clientY - pointerDownPos.current.y;
|
||||
|
||||
if (isClickRef.current && Math.hypot(dx, dy) > CLICK_THRESHOLD) {
|
||||
isClickRef.current = false;
|
||||
setIsDragging(true);
|
||||
}
|
||||
|
||||
if (isClickRef.current) return;
|
||||
|
||||
const rect = wrapperRectRef.current;
|
||||
if (rect && !shouldReduceMotion) {
|
||||
if (e.clientX < rect.left) {
|
||||
rubberStretch.jump(computeRubberStretch(e.clientX, -1));
|
||||
} else if (e.clientX > rect.right) {
|
||||
rubberStretch.jump(computeRubberStretch(e.clientX, 1));
|
||||
} else {
|
||||
rubberStretch.jump(0);
|
||||
}
|
||||
}
|
||||
|
||||
const newValue = positionToValue(e.clientX);
|
||||
animRef.current?.stop();
|
||||
animRef.current = null;
|
||||
fillPercent.jump(percentFromValue(newValue));
|
||||
setValue(roundValue(newValue, step));
|
||||
},
|
||||
[
|
||||
isInteracting,
|
||||
positionToValue,
|
||||
percentFromValue,
|
||||
setValue,
|
||||
step,
|
||||
fillPercent,
|
||||
rubberStretch,
|
||||
computeRubberStretch,
|
||||
shouldReduceMotion,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isInteracting) return;
|
||||
|
||||
if (isClickRef.current) {
|
||||
// Coarse sliders (≤10 positions) snap to the nearest step;
|
||||
// continuous sliders keep the decile-magnetic behavior.
|
||||
const rawValue = positionToValue(e.clientX);
|
||||
const discreteSteps = (max - min) / step;
|
||||
const snapped =
|
||||
discreteSteps <= 10
|
||||
? clamp(min + Math.round((rawValue - min) / step) * step, min, max)
|
||||
: snapToDecile(rawValue, min, max);
|
||||
|
||||
animateFillTo(percentFromValue(snapped));
|
||||
setValue(roundValue(snapped, step));
|
||||
}
|
||||
|
||||
if (!shouldReduceMotion && rubberStretch.get() !== 0) {
|
||||
animate(rubberStretch, 0, {
|
||||
type: "spring",
|
||||
visualDuration: 0.35,
|
||||
bounce: 0.15,
|
||||
});
|
||||
}
|
||||
|
||||
setIsInteracting(false);
|
||||
setIsDragging(false);
|
||||
pointerDownPos.current = null;
|
||||
},
|
||||
[
|
||||
isInteracting,
|
||||
positionToValue,
|
||||
percentFromValue,
|
||||
setValue,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
animateFillTo,
|
||||
rubberStretch,
|
||||
shouldReduceMotion,
|
||||
],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
// Shift + Arrow is a Figma-style fast nudge: jumps by 10x the step,
|
||||
// independent of the WAI-ARIA Page step (which scales with range).
|
||||
const arrowStep = e.shiftKey ? step * 10 : step;
|
||||
|
||||
let next: number | null = null;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowUp":
|
||||
next = value + arrowStep;
|
||||
break;
|
||||
|
||||
case "ArrowLeft":
|
||||
case "ArrowDown":
|
||||
next = value - arrowStep;
|
||||
break;
|
||||
|
||||
case "Home":
|
||||
next = min;
|
||||
break;
|
||||
|
||||
case "End":
|
||||
next = max;
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
setKeyboardFocusRing(true);
|
||||
|
||||
const snapped = roundValue(clamp(next, min, max), step);
|
||||
animateFillTo(percentFromValue(snapped));
|
||||
setValue(snapped);
|
||||
},
|
||||
[value, min, max, step, animateFillTo, percentFromValue, setValue],
|
||||
);
|
||||
|
||||
const handleTrackFocus = useCallback(() => {
|
||||
if (!pendingPointerFocusRef.current) {
|
||||
setKeyboardFocusRing(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTrackBlur = useCallback(() => {
|
||||
setKeyboardFocusRing(false);
|
||||
}, []);
|
||||
|
||||
// Measure label + value to derive "dodge" thresholds so the handle fades
|
||||
// when it would overlap either text.
|
||||
const [dodge, setDodge] = useState({ left: 38, right: 72 });
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
|
||||
useLayoutEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const measure = () => {
|
||||
const trackWidth = wrapper.offsetWidth;
|
||||
if (trackWidth <= 0) return;
|
||||
|
||||
const labelEl = labelRef.current;
|
||||
const valueEl = valueRef.current;
|
||||
|
||||
const left = labelEl
|
||||
? ((LABEL_OFFSET + labelEl.offsetWidth + HANDLE_BUFFER) / trackWidth) *
|
||||
100
|
||||
: 38;
|
||||
|
||||
const right = valueEl
|
||||
? ((trackWidth - VALUE_OFFSET - valueEl.offsetWidth - HANDLE_BUFFER) /
|
||||
trackWidth) *
|
||||
100
|
||||
: 72;
|
||||
|
||||
setDodge((prev) => {
|
||||
return prev.left === left && prev.right === right
|
||||
? prev
|
||||
: { left, right };
|
||||
});
|
||||
};
|
||||
|
||||
measure();
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(wrapper);
|
||||
|
||||
if (labelRef.current) observer.observe(labelRef.current);
|
||||
if (valueRef.current) observer.observe(valueRef.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [label, displayValue]);
|
||||
|
||||
const valueDodge = percentage < dodge.left || percentage > dodge.right;
|
||||
const handleOpacity = !isActive
|
||||
? 0
|
||||
: valueDodge
|
||||
? 0.1
|
||||
: isDragging
|
||||
? 0.8
|
||||
: 0.5;
|
||||
|
||||
const discreteSteps = (max - min) / step;
|
||||
const hashMarkCount = discreteSteps <= 10 ? discreteSteps - 1 : 9;
|
||||
|
||||
const hashMarkPct = (i: number) => {
|
||||
return discreteSteps <= 10
|
||||
? (((i + 1) * step) / (max - min)) * 100
|
||||
: (i + 1) * 10;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
data-slot="elastic-slider"
|
||||
className={cn(
|
||||
"[--elastic-slider-height:--spacing(12)] [--elastic-slider-radius:var(--radius-lg)]",
|
||||
"[--elastic-slider-bg:var(--neutral)]",
|
||||
"[--elastic-slider-fill:var(--main)]/10",
|
||||
"[--elastic-slider-fill-active:var(--main)]/20",
|
||||
"[--elastic-slider-hash:var(--main)]/30",
|
||||
"[--elastic-slider-handle:var(--main)]",
|
||||
"[--elastic-slider-label:var(--main)]/70",
|
||||
"[--elastic-slider-focus:var(--main)]",
|
||||
"relative h-(--elastic-slider-height)",
|
||||
mat.enabled &&
|
||||
"[--elastic-slider-bg:color-mix(in_oklch,var(--neutral),black_4%)] [--elastic-slider-fill:var(--primary)]/35 [--elastic-slider-fill-active:var(--primary)]/55 [--elastic-slider-handle:var(--neutral)] [--elastic-slider-hash:var(--primary)]/40",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
ref={trackRef}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
data-slot="elastic-slider-track"
|
||||
data-active={isActive}
|
||||
data-focus-visible={keyboardFocusRing}
|
||||
aria-label={ariaLabel ?? label}
|
||||
aria-orientation="horizontal"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
aria-valuetext={displayValue}
|
||||
className={cn(
|
||||
"group/elastic-slider absolute inset-0 cursor-pointer touch-none overflow-hidden rounded-(--elastic-slider-radius) bg-(--elastic-slider-bg) outline-none select-none border-main border-solid border-1",
|
||||
"data-[focus-visible=true]:ring-2 data-[focus-visible=true]:ring-(--elastic-slider-focus)/50 data-[focus-visible=true]:ring-offset-1 data-[focus-visible=true]:ring-offset-neutral",
|
||||
mat.enabled && "material-slider-track",
|
||||
)}
|
||||
style={{ width: rubberWidth, x: rubberX }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onFocus={handleTrackFocus}
|
||||
onBlur={handleTrackBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
data-slot="elastic-slider-hash-marks"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
>
|
||||
{Array.from({ length: hashMarkCount }, (_, i) => (
|
||||
<div
|
||||
key={i.toString()}
|
||||
className={cn(
|
||||
"absolute top-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors duration-200",
|
||||
"bg-transparent group-data-[active=true]/elastic-slider:bg-(--elastic-slider-hash)",
|
||||
)}
|
||||
style={{ left: `${hashMarkPct(i)}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
data-slot="elastic-slider-fill"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-y-0 left-0 transition-colors",
|
||||
"bg-(--elastic-slider-fill) group-data-[active=true]/elastic-slider:bg-(--elastic-slider-fill-active)",
|
||||
mat.enabled && "material-slider-fill",
|
||||
)}
|
||||
style={{ width: fillWidth }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
data-slot="elastic-slider-handle"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-1/2 h-5 w-1 rounded-full bg-(--elastic-slider-handle)"
|
||||
style={{ left: handleLeft, y: "-50%" }}
|
||||
animate={{
|
||||
opacity: handleOpacity,
|
||||
scaleX: isActive ? 1 : 0.25,
|
||||
scaleY: isActive && valueDodge ? 0.75 : 1,
|
||||
}}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
scaleX: {
|
||||
type: "spring",
|
||||
visualDuration: 0.25,
|
||||
bounce: 0.15,
|
||||
},
|
||||
scaleY: { type: "spring", visualDuration: 0.2, bounce: 0.1 },
|
||||
opacity: { duration: 0.15 },
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
<span
|
||||
ref={labelRef}
|
||||
data-slot="elastic-slider-label"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-1/2 left-3 inline-flex -translate-y-1/2 items-center text-sm/none font-medium text-(--elastic-slider-label) transition-colors"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<span
|
||||
ref={valueRef}
|
||||
data-slot="elastic-slider-value"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 font-mono text-sm/none font-medium transition-colors",
|
||||
"text-(--elastic-slider-label) group-data-[active=true]/elastic-slider:text-(--elastic-slider-focus)",
|
||||
)}
|
||||
>
|
||||
{formatValue ? (
|
||||
displayValue
|
||||
) : (
|
||||
<AnimateNumber format={animatedFormat}>{value}</AnimateNumber>
|
||||
)}
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { Checkbox } from "./checkbox";
|
||||
|
||||
const meta: Meta<typeof Checkbox> = {
|
||||
title: "form/Checkbox",
|
||||
component: Checkbox,
|
||||
argTypes: {
|
||||
disabled: { control: "boolean" },
|
||||
defaultChecked: { control: "boolean" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Checkbox>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const DefaultChecked: Story = { args: { defaultChecked: true } };
|
||||
|
||||
export const Disabled: Story = { args: { disabled: true } };
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [checked, setChecked] = useState<boolean | "indeterminate">(false);
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={checked} onCheckedChange={setChecked} />
|
||||
Toggle me — state: {String(checked)}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabel: Story = {
|
||||
render: () => (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox defaultChecked />I agree to the terms
|
||||
</label>
|
||||
),
|
||||
};
|
||||
|
||||
export const Indeterminate: Story = {
|
||||
args: { checked: "indeterminate" },
|
||||
};
|
||||
|
||||
export const EnterAnimation: Story = {
|
||||
render: () => {
|
||||
const [nonce, setNonce] = useState(0);
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNonce((n) => n + 1)}
|
||||
className="rounded-button bg-primary px-3 py-1 text-sm text-neutral"
|
||||
>
|
||||
Replay enter
|
||||
</button>
|
||||
<motion.div
|
||||
key={nonce}
|
||||
initial="from"
|
||||
animate="enter"
|
||||
transition={{ delayChildren: 0.05, staggerChildren: 0.08 }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<Checkbox />
|
||||
<Checkbox defaultChecked />
|
||||
<Checkbox />
|
||||
<Checkbox defaultChecked />
|
||||
<Checkbox />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui";
|
||||
import { forwardRef } from "react";
|
||||
import type * as React from "react";
|
||||
import { useAnimation } from "@/animation/provider";
|
||||
import { useControllableState } from "@/hooks/useControllableState";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMaterial } from "@/material/provider";
|
||||
import { useSound } from "@/sound/provider";
|
||||
|
||||
type CheckedState = boolean | "indeterminate";
|
||||
|
||||
type CheckboxRootProps = React.ComponentProps<typeof CheckboxPrimitive.Root>;
|
||||
// Drop the DOM drag handlers so they don't collide with motion's drag types.
|
||||
type MotionSafeCheckboxRootProps = Omit<
|
||||
CheckboxRootProps,
|
||||
"onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
|
||||
>;
|
||||
|
||||
const CheckboxRoot = forwardRef<HTMLButtonElement, MotionSafeCheckboxRootProps>(
|
||||
(props, ref) => <CheckboxPrimitive.Root ref={ref} {...props} />,
|
||||
);
|
||||
CheckboxRoot.displayName = "CheckboxRoot";
|
||||
|
||||
const MCheckboxRoot = motion.create(CheckboxRoot);
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
onCheckedChange,
|
||||
checked,
|
||||
defaultChecked,
|
||||
...props
|
||||
}: MotionSafeCheckboxRootProps) {
|
||||
const { play } = useSound("toggle");
|
||||
const token = useAnimation("checkbox");
|
||||
const mat = useMaterial("checkbox");
|
||||
const [state, setState] = useControllableState<CheckedState>({
|
||||
prop: checked,
|
||||
defaultProp: defaultChecked ?? false,
|
||||
onChange: onCheckedChange,
|
||||
caller: "Checkbox",
|
||||
});
|
||||
|
||||
const drawn = state === true || state === "indeterminate";
|
||||
|
||||
return (
|
||||
<MCheckboxRoot
|
||||
checked={checked}
|
||||
defaultChecked={defaultChecked}
|
||||
onCheckedChange={(next) => {
|
||||
setState(next);
|
||||
play();
|
||||
}}
|
||||
variants={token.variants}
|
||||
transition={token.transition}
|
||||
whileHover={token.whileHover}
|
||||
whileTap={token.whileTap}
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-sm border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-neutral dark:data-checked:bg-primary",
|
||||
mat.enabled && "material-check",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
forceMount
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<motion.path
|
||||
d="M3 7.5 L6 10.5 L11 4.5"
|
||||
initial={false}
|
||||
animate={{
|
||||
pathLength: drawn ? 1 : 0,
|
||||
opacity: drawn ? 1 : 0,
|
||||
}}
|
||||
transition={{
|
||||
pathLength: {
|
||||
duration: 0.25,
|
||||
ease: [0.65, 0, 0.35, 1],
|
||||
},
|
||||
opacity: {
|
||||
duration: 0.1,
|
||||
delay: drawn ? 0 : 0.15,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</MCheckboxRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,29 @@
|
||||
import { type HTMLMotionProps, motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useAnimation } from "@/animation/provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FieldContainerProps = {
|
||||
children: ReactNode;
|
||||
variants?: HTMLMotionProps<"div">["variants"];
|
||||
transition?: HTMLMotionProps<"div">["transition"];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const FieldContainer = ({
|
||||
children,
|
||||
variants,
|
||||
transition,
|
||||
className,
|
||||
}: FieldContainerProps) => {
|
||||
const token = useAnimation("fieldContainer");
|
||||
return (
|
||||
<motion.div
|
||||
variants={variants ?? token.variants}
|
||||
transition={transition ?? token.transition}
|
||||
className={cn("py-3 flex flex-col gap-2", className)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
import { motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { defaultAnimationStagger } from "@/styles/animations.ts";
|
||||
|
||||
export const formVariants = cva(cn("py-2 gap-2 max-w-max-form flex flex-col"));
|
||||
|
||||
export const Form = ({
|
||||
children,
|
||||
id,
|
||||
onSubmit,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
id?: string;
|
||||
onSubmit?: () => void;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<motion.form
|
||||
id={id}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit?.();
|
||||
}}
|
||||
{...defaultAnimationStagger}
|
||||
className={cn(formVariants(), className)}
|
||||
>
|
||||
{children}
|
||||
</motion.form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as React from "react";
|
||||
import { InputText } from "./input-text";
|
||||
|
||||
type Props = Omit<
|
||||
React.ComponentProps<"input">,
|
||||
"value" | "onChange" | "type"
|
||||
> & {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number | string;
|
||||
};
|
||||
|
||||
const InputNumber = React.forwardRef<HTMLInputElement, Props>(
|
||||
({ value, onChange, min, max, onBlur, onFocus, ...rest }, ref) => {
|
||||
const [draft, setDraft] = React.useState<string>(String(value));
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focused && String(value) !== draft) {
|
||||
setDraft(String(value));
|
||||
}
|
||||
}, [value, focused, draft]);
|
||||
|
||||
const commit = (raw: string) => {
|
||||
if (raw === "" || raw === "-") return;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
if (min !== undefined && parsed < min) return;
|
||||
if (max !== undefined && parsed > max) return;
|
||||
if (parsed !== value) onChange(parsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<InputText
|
||||
ref={ref}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={min}
|
||||
max={max}
|
||||
value={draft}
|
||||
onFocus={(e) => {
|
||||
setFocused(true);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setDraft(next);
|
||||
commit(next);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setFocused(false);
|
||||
const parsed = Number(draft);
|
||||
if (draft === "" || !Number.isFinite(parsed)) {
|
||||
setDraft(String(value));
|
||||
} else {
|
||||
let clamped = parsed;
|
||||
if (min !== undefined && clamped < min) clamped = min;
|
||||
if (max !== undefined && clamped > max) clamped = max;
|
||||
setDraft(String(clamped));
|
||||
if (clamped !== value) onChange(clamped);
|
||||
}
|
||||
onBlur?.(e);
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
InputNumber.displayName = "InputNumber";
|
||||
|
||||
export { InputNumber };
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMaterial } from "@/material/provider";
|
||||
import { useSound } from "@/sound/provider";
|
||||
|
||||
const InputText = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentProps<"input">
|
||||
>(({ className, type, onFocus, ...props }, ref) => {
|
||||
const { play } = useSound("focus");
|
||||
const mat = useMaterial("input");
|
||||
|
||||
return (
|
||||
<input
|
||||
onFocus={(e) => {
|
||||
play();
|
||||
onFocus?.(e);
|
||||
}}
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-input-height w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
mat.enabled && "material-input",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
InputText.displayName = "InputText";
|
||||
|
||||
export { InputText };
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Label as LabelPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium text-main leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMaterial } from "@/material/provider";
|
||||
import { useSound } from "@/sound/provider";
|
||||
|
||||
function Select({
|
||||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
const { play: playOpen } = useSound("selectOpen");
|
||||
const { play: playClose } = useSound("selectClose");
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Root
|
||||
onOpenChange={(open) => {
|
||||
if (open) playOpen();
|
||||
else playClose();
|
||||
onOpenChange?.(open);
|
||||
}}
|
||||
data-slot="select"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
const mat = useMaterial("select");
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
data-material={mat.enabled ? "neutral" : undefined}
|
||||
style={
|
||||
mat.enabled
|
||||
? ({ "--mat-base": "var(--neutral)", ...style } as React.CSSProperties)
|
||||
: style
|
||||
}
|
||||
className={cn(
|
||||
"border-main data-placeholder:text-secondary [&_svg:not([class*='text-'])]:text-secondary focus-visible:border-ring",
|
||||
"focus-visible:ring-main/50 aria-invalid:ring-error/20 dark:aria-invalid:ring-error/40 aria-invalid:border-error",
|
||||
"dark:bg-neutral-accent/30 dark:hover:bg-neutral-accent/50 flex w-fit items-center justify-between gap-2 rounded-lg",
|
||||
"border bg-transparent px-4 py-2 text-sm text-main whitespace-nowrap shadow-xs transition-[color,box-shadow,background-color] outline-none",
|
||||
"focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-input-height data-[size=sm]:h-8",
|
||||
"*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center",
|
||||
"*:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
mat.enabled && "material",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
const mat = useMaterial("select");
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-material={mat.enabled ? "neutral" : undefined}
|
||||
style={
|
||||
mat.enabled
|
||||
? ({ "--mat-base": "var(--neutral)", ...style } as React.CSSProperties)
|
||||
: style
|
||||
}
|
||||
className={cn(
|
||||
"bg-neutral data-[state=open]:animate-in data-[state=closed]:animate-out p-1",
|
||||
"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 relative z-100 max-h-(--radix-select-content-available-height) min-w-32",
|
||||
"origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
mat.enabled && "material",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-main px-3 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
onMouseEnter,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
const { play } = useSound("hover");
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
onMouseEnter={(e) => {
|
||||
play();
|
||||
onMouseEnter?.(e);
|
||||
}}
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-main/25 text-main/75 focus:text-main [&_svg:not([class*='text-'])]:text-main transition-colors",
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-3",
|
||||
"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 *:[span]:last:flex",
|
||||
"*:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-main pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMaterial } from "@/material/provider";
|
||||
import { useSound } from "@/sound/provider";
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, onFocus, ...props }, ref) => {
|
||||
const { play } = useSound("focus");
|
||||
const mat = useMaterial("input");
|
||||
|
||||
return (
|
||||
<textarea
|
||||
onFocus={(e) => {
|
||||
play();
|
||||
onFocus?.(e);
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-h-[5rem] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
mat.enabled && "material-input",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare module "*.mp3";
|
||||
declare module "*.wav";
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as React from "react";
|
||||
|
||||
// use-layout-effect.tsx
|
||||
// https://github.com/radix-ui/primitives/blob/main/packages/react/use-layout-effect/src/use-layout-effect.tsx
|
||||
|
||||
/**
|
||||
* On the server, React emits a warning when calling `useLayoutEffect`.
|
||||
* This is because neither `useLayoutEffect` nor `useEffect` run on the server.
|
||||
* We use this safe version which suppresses the warning by replacing it with a noop on the server.
|
||||
*
|
||||
* See: https://reactjs.org/docs/hooks-reference.html#uselayouteffect
|
||||
*/
|
||||
const useLayoutEffect = globalThis?.document ? React.useLayoutEffect : () => {};
|
||||
|
||||
// use-controllable-state.tsx
|
||||
// https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/use-controllable-state.tsx
|
||||
|
||||
// Prevent bundlers from trying to optimize the import
|
||||
const useInsertionEffect: typeof useLayoutEffect =
|
||||
(React as never)[" useInsertionEffect ".trim().toString()] || useLayoutEffect;
|
||||
|
||||
type ChangeHandler<T> = (state: T) => void;
|
||||
type SetStateFn<T> = React.Dispatch<React.SetStateAction<T>>;
|
||||
|
||||
interface UseControllableStateParams<T> {
|
||||
prop?: T | undefined;
|
||||
defaultProp: T;
|
||||
onChange?: ChangeHandler<T>;
|
||||
caller?: string;
|
||||
}
|
||||
|
||||
export function useControllableState<T>({
|
||||
prop,
|
||||
defaultProp,
|
||||
onChange = () => {},
|
||||
caller,
|
||||
}: UseControllableStateParams<T>): [T, SetStateFn<T>] {
|
||||
const [uncontrolledProp, setUncontrolledProp, onChangeRef] =
|
||||
useUncontrolledState({
|
||||
defaultProp,
|
||||
onChange,
|
||||
});
|
||||
const isControlled = prop !== undefined;
|
||||
const value = isControlled ? prop : uncontrolledProp;
|
||||
|
||||
// OK to disable conditionally calling hooks here because they will always run
|
||||
// consistently in the same environment. Bundlers should be able to remove the
|
||||
// code block entirely in production.
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: <explanation>
|
||||
const isControlledRef = React.useRef(prop !== undefined);
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: <explanation>
|
||||
React.useEffect(() => {
|
||||
const wasControlled = isControlledRef.current;
|
||||
if (wasControlled !== isControlled) {
|
||||
const from = wasControlled ? "controlled" : "uncontrolled";
|
||||
const to = isControlled ? "controlled" : "uncontrolled";
|
||||
console.warn(
|
||||
`${caller} is changing from ${from} to ${to}. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.`,
|
||||
);
|
||||
}
|
||||
isControlledRef.current = isControlled;
|
||||
}, [isControlled, caller]);
|
||||
}
|
||||
/* eslint-enable react-hooks/rules-of-hooks */
|
||||
|
||||
const setValue = React.useCallback<SetStateFn<T>>(
|
||||
(nextValue) => {
|
||||
if (isControlled) {
|
||||
const value = isFunction(nextValue) ? nextValue(prop) : nextValue;
|
||||
if (value !== prop) {
|
||||
onChangeRef.current?.(value);
|
||||
}
|
||||
} else {
|
||||
setUncontrolledProp(nextValue);
|
||||
}
|
||||
},
|
||||
[isControlled, prop, setUncontrolledProp, onChangeRef],
|
||||
);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
function useUncontrolledState<T>({
|
||||
defaultProp,
|
||||
onChange,
|
||||
}: Omit<UseControllableStateParams<T>, "prop">): [
|
||||
Value: T,
|
||||
setValue: React.Dispatch<React.SetStateAction<T>>,
|
||||
OnChangeRef: React.RefObject<ChangeHandler<T> | undefined>,
|
||||
] {
|
||||
const [value, setValue] = React.useState(defaultProp);
|
||||
const prevValueRef = React.useRef(value);
|
||||
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
useInsertionEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
|
||||
React.useEffect(() => {
|
||||
if (prevValueRef.current !== value) {
|
||||
onChangeRef.current?.(value);
|
||||
prevValueRef.current = value;
|
||||
}
|
||||
}, [value, prevValueRef]);
|
||||
|
||||
return [value, setValue, onChangeRef];
|
||||
}
|
||||
|
||||
function isFunction(value: unknown): value is (...args: never[]) => unknown {
|
||||
return typeof value === "function";
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function getRandomInt(min: number, max: number) {
|
||||
const ceiledMin = Math.ceil(min);
|
||||
const flooredMax = Math.floor(max);
|
||||
|
||||
return Math.floor(Math.random() * (flooredMax - ceiledMin) + ceiledMin);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { MaterialTheme } from "./types";
|
||||
|
||||
/**
|
||||
* Everything OFF by default. With no <MaterialProvider> mounted (every game that
|
||||
* hasn't opted in), components read this and render exactly as before — so the
|
||||
* material is a zero-regression, opt-in feature. A game enables the components
|
||||
* it wants by passing a partial theme to <MaterialProvider>.
|
||||
*/
|
||||
export const defaultMaterialTheme: MaterialTheme = {
|
||||
button: {},
|
||||
card: {},
|
||||
dialog: {},
|
||||
tabs: {},
|
||||
select: {},
|
||||
input: {},
|
||||
checkbox: {},
|
||||
toast: {},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export { defaultMaterialTheme } from "./defaults";
|
||||
export { mergeMaterialTheme } from "./merge";
|
||||
export {
|
||||
MaterialProvider,
|
||||
type MaterialProviderProps,
|
||||
useMaterial,
|
||||
useMaterialTheme,
|
||||
} from "./provider";
|
||||
export type {
|
||||
MaterialPreset,
|
||||
MaterialTheme,
|
||||
MaterialToken,
|
||||
PartialMaterialTheme,
|
||||
} from "./types";
|
||||
export { isWebkit, useSpecular } from "./use-specular";
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { MaterialTheme, PartialMaterialTheme } from "./types";
|
||||
|
||||
function setMerged<K extends keyof MaterialTheme>(
|
||||
out: MaterialTheme,
|
||||
key: K,
|
||||
base: MaterialTheme,
|
||||
partial: Partial<MaterialTheme[K]>,
|
||||
) {
|
||||
// Shallow per-token merge, matching the animation provider.
|
||||
out[key] = { ...base[key], ...partial };
|
||||
}
|
||||
|
||||
export function mergeMaterialTheme(
|
||||
base: MaterialTheme,
|
||||
override: PartialMaterialTheme,
|
||||
): MaterialTheme {
|
||||
const out = { ...base } as MaterialTheme;
|
||||
for (const key of Object.keys(override) as (keyof MaterialTheme)[]) {
|
||||
const partial = override[key];
|
||||
if (!partial) continue;
|
||||
setMerged(out, key, base, partial);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { defaultMaterialTheme } from "./defaults";
|
||||
import { mergeMaterialTheme } from "./merge";
|
||||
import type { MaterialTheme, PartialMaterialTheme } from "./types";
|
||||
|
||||
const MaterialContext = createContext<MaterialTheme>(defaultMaterialTheme);
|
||||
|
||||
export type MaterialProviderProps = {
|
||||
theme: PartialMaterialTheme;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function MaterialProvider({ theme, children }: MaterialProviderProps) {
|
||||
const parent = useContext(MaterialContext);
|
||||
// Enable material only AFTER mount. The material renders extra DOM (the
|
||||
// `.material-fx` layer + `data-material`), so if the provider value differed
|
||||
// between server and client it would cause a hydration mismatch (#418).
|
||||
// Provider trees aren't always applied identically during SSR (e.g. the
|
||||
// app shell/header), so to be safe-by-construction we provide the inherited
|
||||
// value (default = OFF) during SSR and the first hydration render, then flip
|
||||
// to the merged theme one tick later — exactly like the auth session bridge.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
const merged = useMemo(
|
||||
() => (mounted ? mergeMaterialTheme(parent, theme) : parent),
|
||||
[mounted, parent, theme],
|
||||
);
|
||||
return (
|
||||
<MaterialContext.Provider value={merged}>
|
||||
{children}
|
||||
</MaterialContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMaterial<K extends keyof MaterialTheme>(
|
||||
key: K,
|
||||
): MaterialTheme[K] {
|
||||
return useContext(MaterialContext)[key];
|
||||
}
|
||||
|
||||
export function useMaterialTheme(): MaterialTheme {
|
||||
return useContext(MaterialContext);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Which visual treatment a surface gets. */
|
||||
export type MaterialPreset = "solid" | "neutral" | "none";
|
||||
|
||||
export type MaterialToken = {
|
||||
/** Master switch for this component. Default off → no material, no regression. */
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Follow the pointer with the specular hot-spot. Components additionally gate
|
||||
* this on `isWebkit()` for large surfaces (cards) where the live-blur repaint
|
||||
* bug is visible. Default true when enabled.
|
||||
*/
|
||||
trackPointer?: boolean;
|
||||
/** Force a single preset regardless of the component's variant. */
|
||||
preset?: MaterialPreset;
|
||||
/** Per-variant preset overrides, keyed by the component's variant name. */
|
||||
variants?: Record<string, MaterialPreset>;
|
||||
};
|
||||
|
||||
/** Flat record keyed by component, mirroring AnimationTheme. */
|
||||
export type MaterialTheme = {
|
||||
button: MaterialToken;
|
||||
card: MaterialToken;
|
||||
dialog: MaterialToken;
|
||||
tabs: MaterialToken;
|
||||
select: MaterialToken;
|
||||
input: MaterialToken;
|
||||
checkbox: MaterialToken;
|
||||
toast: MaterialToken;
|
||||
};
|
||||
|
||||
export type PartialMaterialTheme = {
|
||||
[K in keyof MaterialTheme]?: Partial<MaterialTheme[K]>;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
import type { PointerEvent } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
/**
|
||||
* True for WebKit engines — desktop Safari and every iOS browser. These have a
|
||||
* partial-repaint bug where moving content inside a `filter: blur()` layer only
|
||||
* re-rasterises the dirty rect under the cursor, so pointer-tracked specular
|
||||
* leaves trails. Chromium/Firefox are fine. `navigator.vendor` is
|
||||
* "Apple Computer, Inc." for Safari + all iOS browsers, "Google Inc." for Chrome.
|
||||
*/
|
||||
export function isWebkit(): boolean {
|
||||
return (
|
||||
typeof navigator !== "undefined" &&
|
||||
navigator.vendor === "Apple Computer, Inc."
|
||||
);
|
||||
}
|
||||
|
||||
const noop = (_e: PointerEvent<HTMLElement>) => {};
|
||||
|
||||
/**
|
||||
* Pointer-tracked specular: updates `--mx` / `--my` on the hovered element so the
|
||||
* `.material-spot` highlight follows the cursor (smoothed by the `@property`
|
||||
* values in material.css). On leave it eases back to a top-centre rest.
|
||||
*
|
||||
* Pass `enabled = false` to get inert handlers (e.g. on WebKit, where tracking a
|
||||
* live-blur layer flickers — the highlight then stays put). Returns ready-to-
|
||||
* spread handlers that read `e.currentTarget`, so no ref is needed.
|
||||
*/
|
||||
export function useSpecular(enabled = true) {
|
||||
return useMemo(() => {
|
||||
if (!enabled) return { onPointerMove: noop, onPointerLeave: noop };
|
||||
return {
|
||||
onPointerMove: (e: PointerEvent<HTMLElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const r = el.getBoundingClientRect();
|
||||
el.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
|
||||
el.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
|
||||
},
|
||||
onPointerLeave: (e: PointerEvent<HTMLElement>) => {
|
||||
const el = e.currentTarget;
|
||||
el.style.setProperty("--mx", "50%");
|
||||
el.style.setProperty("--my", "30%");
|
||||
},
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
let installed = false;
|
||||
|
||||
/**
|
||||
* Reload once when a dynamic import of a route/chunk fails. This happens when a
|
||||
* client still running a *previous* build navigates after a new deploy has
|
||||
* replaced the hashed chunk files — the lazy import 404s with "Importing a
|
||||
* module script failed". Vite dispatches `vite:preloadError`; we reload so the
|
||||
* user picks up the fresh build instead of crashing.
|
||||
*
|
||||
* - No-op on the server (guards `window`).
|
||||
* - Idempotent — safe to call more than once; only the first registers.
|
||||
* - Time-guarded (`graceMs`, default 10s): if a chunk is genuinely missing the
|
||||
* reload won't loop, but a later deploy in the same session can reload again.
|
||||
*
|
||||
* Call once at client startup, e.g. at module scope in the app's `router.tsx`.
|
||||
*/
|
||||
export function reloadOnStaleChunk(graceMs = 10_000): void {
|
||||
if (typeof window === "undefined" || installed) return;
|
||||
installed = true;
|
||||
window.addEventListener("vite:preloadError", () => {
|
||||
const KEY = "vite:preloadReloadedAt";
|
||||
const last = Number(sessionStorage.getItem(KEY) ?? 0);
|
||||
if (Date.now() - last < graceMs) return;
|
||||
sessionStorage.setItem(KEY, String(Date.now()));
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SerializedHeadingNode } from "@payloadcms/richtext-lexical";
|
||||
import type { JSXConverters } from "@payloadcms/richtext-lexical/react";
|
||||
|
||||
export const headingConverter: JSXConverters<SerializedHeadingNode> = {
|
||||
heading: ({ node, nodesToJSX }) => {
|
||||
if (node.tag === "h2") {
|
||||
const text = nodesToJSX({ nodes: node.children });
|
||||
|
||||
const id = text
|
||||
.join("")
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
return <h2 id={id}>{text}</h2>;
|
||||
} else {
|
||||
const text = nodesToJSX({ nodes: node.children }).join("");
|
||||
const Tag = node.tag;
|
||||
return <Tag>{text}</Tag>;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { DefaultNodeTypes } from "@payloadcms/richtext-lexical";
|
||||
import {
|
||||
type JSXConvertersFunction,
|
||||
LinkJSXConverter,
|
||||
} from "@payloadcms/richtext-lexical/react";
|
||||
import { headingConverter } from "./headings";
|
||||
import { internalDocToHref } from "./internal-link";
|
||||
|
||||
type NodeTypes = DefaultNodeTypes;
|
||||
|
||||
// Insert blocks like:
|
||||
// | SerializedBlockNode<TableOfContentsProps | ContentWithMediaProps>;
|
||||
|
||||
export const jsxConverter: JSXConvertersFunction<NodeTypes> = ({
|
||||
defaultConverters,
|
||||
}) => ({
|
||||
...defaultConverters,
|
||||
...LinkJSXConverter({ internalDocToHref }),
|
||||
...headingConverter,
|
||||
blocks: {
|
||||
/* contentWithMedia: ({ node }) => <ContentWithMedia {...node.fields} />,
|
||||
tableOfContents: ({ node }) => <TableOfContents {...node.fields} />,
|
||||
*/
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SerializedLinkNode } from "@payloadcms/richtext-lexical";
|
||||
|
||||
export const internalDocToHref = ({
|
||||
linkNode,
|
||||
}: {
|
||||
linkNode: SerializedLinkNode;
|
||||
}) => {
|
||||
// biome-ignore lint/style/noNonNullAssertion: safe
|
||||
const { value, relationTo } = linkNode.fields.doc!;
|
||||
|
||||
const slug =
|
||||
typeof value !== "string" && typeof value !== "number" && value.slug;
|
||||
|
||||
if (relationTo === "posts") {
|
||||
return `/posts/${slug}`;
|
||||
} else if (relationTo === "users") {
|
||||
return `/users/${slug}`;
|
||||
} else {
|
||||
return `/${slug}`;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { SerializedEditorState } from "@payloadcms/richtext-lexical/lexical";
|
||||
import { RichText as RichTextConverter } from "@payloadcms/richtext-lexical/react";
|
||||
import { jsxConverter } from "./converters";
|
||||
|
||||
type Props = {
|
||||
data: SerializedEditorState;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export function RichText(props: Props) {
|
||||
const { className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<RichTextConverter
|
||||
{...rest}
|
||||
className={className}
|
||||
converters={jsxConverter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { motion } from "motion/react";
|
||||
import type { FC, ReactElement, ReactNode } from "react";
|
||||
import { useAnimation } from "@/animation/provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Text from "@/text";
|
||||
|
||||
type HeadingProps = {
|
||||
/*** Is this the main heading of the page? Uses h1 if true. */
|
||||
main?: boolean;
|
||||
/*** Subtitle for additional information */
|
||||
subtitle?: ReactElement | string;
|
||||
/*** IconComponent showed above title */
|
||||
IconComponent?: ReactElement;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const headingVariants = cva("max-w-[800px]", {
|
||||
variants: {
|
||||
padding: {
|
||||
true: "py-4",
|
||||
false: "p-0",
|
||||
},
|
||||
color: {
|
||||
main: "text-main",
|
||||
secondary: "text-main/70",
|
||||
neutral: "text-neutral",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
color: "main",
|
||||
},
|
||||
});
|
||||
|
||||
const Heading: FC<HeadingProps & VariantProps<typeof headingVariants>> = ({
|
||||
children,
|
||||
main = false,
|
||||
IconComponent,
|
||||
padding,
|
||||
color,
|
||||
subtitle,
|
||||
className,
|
||||
}) => {
|
||||
const iconToken = useAnimation("headingIcon");
|
||||
return (
|
||||
<div className="relative mb-8">
|
||||
{IconComponent && (
|
||||
<>
|
||||
<div
|
||||
className="z-20 absolute w-full h-24 top-8 -left-8
|
||||
bg-linear-to-t from-neutral to-transparent"
|
||||
/>
|
||||
|
||||
<div className="relative z-10">
|
||||
<motion.div
|
||||
className="text-highlight
|
||||
drop-shadow-[0px_0px_10px_hsl(var(--color-highlight)/0.5)]
|
||||
w-fit h-fit"
|
||||
transition={iconToken.transition}
|
||||
variants={iconToken.variants}
|
||||
>
|
||||
{IconComponent}
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="relative z-30 mb-2">
|
||||
<Text
|
||||
tag={main ? "h1" : "h2"}
|
||||
className={cn(
|
||||
headingVariants({ className, padding, color }),
|
||||
"my-4",
|
||||
subtitle ? "mb-2" : main ? "mb-2" : "mb-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
tag="h3"
|
||||
className="max-w-[600px]"
|
||||
color={color === "neutral" ? "neutral" : "secondary"}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Heading;
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
import { motion, useInView } from "motion/react";
|
||||
import { createContext, type FC, useRef } from "react";
|
||||
import { useAnimation } from "@/animation/provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SectionProps } from "./section.types";
|
||||
|
||||
export const InViewContext = createContext(false);
|
||||
|
||||
const Section: FC<SectionProps> = ({
|
||||
children,
|
||||
name,
|
||||
extraStyles,
|
||||
maxWidth = true,
|
||||
className,
|
||||
extraClassNames,
|
||||
threshold = 0,
|
||||
height = "fit-content",
|
||||
transition,
|
||||
noPadding = false,
|
||||
variants,
|
||||
}) => {
|
||||
const token = useAnimation("section");
|
||||
const resolvedTransition = transition ?? token.transition;
|
||||
const resolvedVariants = variants ?? token.variants;
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const inView = useInView(ref, { once: true, amount: threshold });
|
||||
|
||||
return (
|
||||
<section
|
||||
{...(name ? { id: name } : {})}
|
||||
ref={ref}
|
||||
style={{
|
||||
height: height,
|
||||
...extraStyles,
|
||||
}}
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
noPadding ? "p-0" : "p-main",
|
||||
maxWidth ? "max-w-max-section m-auto" : "",
|
||||
// `extraClassNames` is the legacy alias; merged in so old
|
||||
// callers keep working until they migrate to `className`.
|
||||
extraClassNames,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<InViewContext.Provider value={inView}>
|
||||
<motion.div
|
||||
className="h-full"
|
||||
transition={resolvedTransition}
|
||||
initial="from"
|
||||
animate={inView ? "enter" : "from"}
|
||||
exit="exit"
|
||||
variants={resolvedVariants?.container}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</InViewContext.Provider>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Transition, Variant } from "motion/react";
|
||||
import type React from "react";
|
||||
|
||||
export interface SectionProps {
|
||||
children: React.ReactNode;
|
||||
styles?: {
|
||||
container?: string;
|
||||
innerContainer?: string;
|
||||
};
|
||||
height?: string;
|
||||
threshold?: number;
|
||||
extraStyles?: object;
|
||||
className?: string;
|
||||
/**
|
||||
* @deprecated Use `className` instead. Kept so existing callers keep
|
||||
* working — the value is merged into `className` at render time.
|
||||
*/
|
||||
extraClassNames?: string;
|
||||
name?: string;
|
||||
transition?: Transition;
|
||||
noPadding?: boolean;
|
||||
maxWidth?: boolean;
|
||||
variants?: {
|
||||
container: { enter: Variant; from: Variant; exit: Variant };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { SoundDef, SoundTheme } from "./types";
|
||||
|
||||
const buttonsSrc = new URL(
|
||||
"../assets/sounds/762132__ienba__ui-buttons.wav",
|
||||
import.meta.url,
|
||||
).href;
|
||||
const uiSetSrc = new URL(
|
||||
"../assets/sounds/842498__newlocknew__uimvmt_game-user-interface-sound-set.mp3",
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
const buttonsSpriteMap = {
|
||||
click1: [0, 1000],
|
||||
click2: [2750, 1000],
|
||||
click3: [5200, 1000],
|
||||
click4: [7700, 1000],
|
||||
} satisfies Record<string, [number, number]>;
|
||||
|
||||
const uiSetSpriteMap = {
|
||||
smooth1: [0, 1200],
|
||||
smooth2: [7800, 1400],
|
||||
lobbyCreated: [53800, 3500],
|
||||
gameStart: [62000, 4000],
|
||||
} satisfies Record<string, [number, number]>;
|
||||
|
||||
const fromButtons = (
|
||||
sprite: keyof typeof buttonsSpriteMap,
|
||||
extra?: Partial<SoundDef>,
|
||||
): SoundDef => ({
|
||||
src: buttonsSrc,
|
||||
sprite,
|
||||
spriteMap: buttonsSpriteMap,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const fromUiSet = (
|
||||
sprite: keyof typeof uiSetSpriteMap,
|
||||
extra?: Partial<SoundDef>,
|
||||
): SoundDef => ({
|
||||
src: uiSetSrc,
|
||||
sprite,
|
||||
spriteMap: uiSetSpriteMap,
|
||||
...extra,
|
||||
});
|
||||
|
||||
export const defaultSoundTheme: SoundTheme = {
|
||||
volume: 1,
|
||||
muted: false,
|
||||
custom: {},
|
||||
tokens: {
|
||||
click: fromButtons("click1", { pool: 4, interrupt: true }),
|
||||
hover: fromButtons("click2", { pool: 4, interrupt: true, volume: 0.6 }),
|
||||
toggle: fromButtons("click2", { pool: 2 }),
|
||||
focus: fromButtons("click2", { pool: 1, volume: 0.5 }),
|
||||
selectOpen: fromButtons("click3"),
|
||||
selectClose: fromButtons("click4"),
|
||||
lobbyCreated: fromUiSet("lobbyCreated"),
|
||||
gameStart: fromUiSet("gameStart"),
|
||||
gameWon: fromUiSet("gameStart"),
|
||||
roundSuccess: fromUiSet("smooth1"),
|
||||
userJoined: fromUiSet("smooth1", { volume: 0.7 }),
|
||||
userLeft: fromUiSet("smooth2", { volume: 0.7 }),
|
||||
error: fromButtons("click4", { volume: 0.8 }),
|
||||
submit: fromButtons("click3"),
|
||||
vote: fromButtons("click2"),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export { defaultSoundTheme } from "./defaults";
|
||||
export { mergeTheme } from "./merge";
|
||||
export {
|
||||
SoundProvider,
|
||||
type SoundProviderProps,
|
||||
useSound,
|
||||
useSoundController,
|
||||
useSoundSettings,
|
||||
useSoundTheme,
|
||||
} from "./provider";
|
||||
export {
|
||||
type AnySoundKey,
|
||||
type CoreSoundKey,
|
||||
type CustomSoundKey,
|
||||
type PartialSoundTheme,
|
||||
type PlayOptions,
|
||||
type PlayUrlOptions,
|
||||
type SoundController,
|
||||
type SoundCustomTokens,
|
||||
type SoundDef,
|
||||
type SoundTheme,
|
||||
WIN_SOUND_VOLUME,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { PartialSoundTheme, SoundTheme } from "./types";
|
||||
|
||||
export function mergeTheme(
|
||||
base: SoundTheme,
|
||||
override: PartialSoundTheme,
|
||||
): SoundTheme {
|
||||
const tokens = { ...base.tokens };
|
||||
if (override.tokens) {
|
||||
for (const key of Object.keys(
|
||||
override.tokens,
|
||||
) as (keyof typeof tokens)[]) {
|
||||
const next = override.tokens[key];
|
||||
// `undefined` inherits from parent; explicit `null` silences this token.
|
||||
if (next !== undefined) tokens[key] = next;
|
||||
}
|
||||
}
|
||||
return {
|
||||
tokens,
|
||||
volume: override.volume ?? base.volume,
|
||||
muted: override.muted ?? base.muted,
|
||||
custom: { ...base.custom, ...(override.custom ?? {}) },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Howl as HowlType } from "howler";
|
||||
import type { SoundDef } from "./types";
|
||||
|
||||
let howlerPromise: Promise<typeof import("howler")> | null = null;
|
||||
const loadHowler = () => (howlerPromise ??= import("howler"));
|
||||
|
||||
function howlKey(def: SoundDef): string {
|
||||
const src = Array.isArray(def.src) ? def.src.join("|") : def.src;
|
||||
if (!def.spriteMap) return src;
|
||||
const fp = Object.entries(def.spriteMap)
|
||||
.map(([k, [a, b]]) => `${k}:${a},${b}`)
|
||||
.sort()
|
||||
.join(";");
|
||||
return `${src}#${fp}`;
|
||||
}
|
||||
|
||||
const howlCache = new Map<string, Promise<HowlType>>();
|
||||
|
||||
export function getHowl(def: SoundDef): Promise<HowlType> {
|
||||
const key = howlKey(def);
|
||||
const cached = howlCache.get(key);
|
||||
if (cached) return cached;
|
||||
const created = loadHowler().then(
|
||||
({ Howl }) =>
|
||||
new Howl({
|
||||
src: typeof def.src === "string" ? [def.src] : def.src,
|
||||
sprite: def.spriteMap as
|
||||
| Record<string, [number, number]>
|
||||
| undefined,
|
||||
preload: true,
|
||||
pool: def.pool ?? 1,
|
||||
}),
|
||||
);
|
||||
howlCache.set(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
const URL_CACHE_MAX = 32;
|
||||
const urlCache = new Map<string, Promise<HowlType>>();
|
||||
|
||||
export function getUrlHowl(url: string): Promise<HowlType> {
|
||||
const cached = urlCache.get(url);
|
||||
if (cached) return cached;
|
||||
if (urlCache.size >= URL_CACHE_MAX) {
|
||||
const oldest = urlCache.keys().next().value;
|
||||
if (oldest) {
|
||||
urlCache
|
||||
.get(oldest)
|
||||
?.then((h) => h.unload())
|
||||
.catch(() => {});
|
||||
urlCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
const created = loadHowler().then(
|
||||
({ Howl }) =>
|
||||
new Howl({ src: [url], html5: true, preload: false, pool: 1 }),
|
||||
);
|
||||
urlCache.set(url, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
export function playDef(
|
||||
def: SoundDef,
|
||||
masterVolume: number,
|
||||
callOpts?: { volume?: number; rate?: number },
|
||||
): void {
|
||||
getHowl(def)
|
||||
.then((howl) => {
|
||||
if (def.interrupt) howl.stop();
|
||||
const id = def.sprite ? howl.play(def.sprite) : howl.play();
|
||||
howl.volume((callOpts?.volume ?? def.volume ?? 1) * masterVolume, id);
|
||||
const rate = callOpts?.rate ?? def.rate;
|
||||
if (rate) howl.rate(rate, id);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export function stopDef(def: SoundDef): void {
|
||||
getHowl(def)
|
||||
.then((howl) => howl.stop())
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export function stopAll(): void {
|
||||
loadHowler()
|
||||
.then(({ Howler }) => Howler.stop())
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { defaultSoundTheme } from "./defaults";
|
||||
import { mergeTheme } from "./merge";
|
||||
import { getUrlHowl, playDef, stopAll as stopAllPlayers, stopDef } from "./player";
|
||||
import type {
|
||||
AnySoundKey,
|
||||
CoreSoundKey,
|
||||
PartialSoundTheme,
|
||||
PlayOptions,
|
||||
PlayUrlOptions,
|
||||
SoundController,
|
||||
SoundDef,
|
||||
SoundTheme,
|
||||
} from "./types";
|
||||
|
||||
const SoundContext = createContext<SoundTheme>(defaultSoundTheme);
|
||||
const ControllerContext = createContext<SoundController | null>(null);
|
||||
|
||||
type PersistedSettings = { volume?: number; muted?: boolean };
|
||||
|
||||
function readPersisted(key: string): PersistedSettings | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
return parsed as PersistedSettings;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePersisted(key: string, value: PersistedSettings) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// quota / disabled — ignore
|
||||
}
|
||||
}
|
||||
|
||||
export type SoundProviderProps = {
|
||||
theme: PartialSoundTheme;
|
||||
/** When set, volume/mute persist to localStorage under this key. */
|
||||
persistKey?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function SoundProvider({
|
||||
theme,
|
||||
persistKey,
|
||||
children,
|
||||
}: SoundProviderProps) {
|
||||
const parent = useContext(SoundContext);
|
||||
|
||||
// Default for SSR + the FIRST hydration render so server and client agree.
|
||||
// Reading localStorage here instead would render e.g. the muted icon on the
|
||||
// client but the unmuted icon on the server → React #418 (which regenerates
|
||||
// the document and wipes the theme class). Persisted settings are applied
|
||||
// only AFTER mount, one tick later.
|
||||
const [overrides, setOverrides] = useState<
|
||||
Pick<PartialSoundTheme, "volume" | "muted">
|
||||
>({});
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (persistKey) {
|
||||
const persisted = readPersisted(persistKey);
|
||||
if (persisted) setOverrides(persisted);
|
||||
}
|
||||
setHydrated(true);
|
||||
}, [persistKey]);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't persist until after the first read, or the initial {} clobbers it.
|
||||
if (!persistKey || !hydrated) return;
|
||||
writePersisted(persistKey, {
|
||||
volume: overrides.volume,
|
||||
muted: overrides.muted,
|
||||
});
|
||||
}, [persistKey, hydrated, overrides.volume, overrides.muted]);
|
||||
|
||||
const merged = useMemo(
|
||||
() => mergeTheme(parent, { ...theme, ...overrides }),
|
||||
[parent, theme, overrides],
|
||||
);
|
||||
|
||||
// Stable themeRef so controller callbacks never need to re-create.
|
||||
const themeRef = useRef(merged);
|
||||
themeRef.current = merged;
|
||||
|
||||
const controller = useMemo<SoundController>(() => {
|
||||
const resolve = (key: string): SoundDef | null => {
|
||||
const t = themeRef.current;
|
||||
if (key in t.tokens) return t.tokens[key as CoreSoundKey];
|
||||
return t.custom[key] ?? null;
|
||||
};
|
||||
return {
|
||||
resolve,
|
||||
play: (key, opts?: PlayOptions) => {
|
||||
const t = themeRef.current;
|
||||
if (t.muted) return;
|
||||
const def = resolve(key as string);
|
||||
if (!def) return;
|
||||
playDef(def, t.volume, opts);
|
||||
},
|
||||
playUrl: (url, opts?: PlayUrlOptions) => {
|
||||
const t = themeRef.current;
|
||||
if (t.muted) return;
|
||||
getUrlHowl(url)
|
||||
.then((howl) => {
|
||||
if (opts?.stopOthers) howl.stop();
|
||||
const id = howl.play();
|
||||
howl.volume((opts?.volume ?? 1) * t.volume, id);
|
||||
if (opts?.rate) howl.rate(opts.rate, id);
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
stop: (key) => {
|
||||
const def = resolve(key as string);
|
||||
if (!def) return;
|
||||
stopDef(def);
|
||||
},
|
||||
stopAll: () => stopAllPlayers(),
|
||||
setVolume: (v) =>
|
||||
setOverrides((o) => ({ ...o, volume: Math.max(0, Math.min(1, v)) })),
|
||||
setMuted: (m) => setOverrides((o) => ({ ...o, muted: m })),
|
||||
get volume() {
|
||||
return themeRef.current.volume;
|
||||
},
|
||||
get muted() {
|
||||
return themeRef.current.muted;
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SoundContext.Provider value={merged}>
|
||||
<ControllerContext.Provider value={controller}>
|
||||
{children}
|
||||
</ControllerContext.Provider>
|
||||
</SoundContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const noopController: SoundController = {
|
||||
resolve: () => null,
|
||||
play: () => {},
|
||||
playUrl: () => {},
|
||||
stop: () => {},
|
||||
stopAll: () => {},
|
||||
setVolume: () => {},
|
||||
setMuted: () => {},
|
||||
volume: 1,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
export function useSoundController(): SoundController {
|
||||
return useContext(ControllerContext) ?? noopController;
|
||||
}
|
||||
|
||||
export function useSoundTheme(): SoundTheme {
|
||||
return useContext(SoundContext);
|
||||
}
|
||||
|
||||
/** Subscribe to a single token; returns referentially-stable `play`/`stop`. */
|
||||
export function useSound(key: AnySoundKey | (string & {})) {
|
||||
const ctrl = useSoundController();
|
||||
return useMemo(
|
||||
() => ({
|
||||
play: (opts?: PlayOptions) => ctrl.play(key, opts),
|
||||
stop: () => ctrl.stop(key),
|
||||
}),
|
||||
[ctrl, key],
|
||||
);
|
||||
}
|
||||
|
||||
/** Subscribe to the live master volume/mute values (for settings UIs that need to react). */
|
||||
export function useSoundSettings() {
|
||||
const theme = useSoundTheme();
|
||||
const ctrl = useSoundController();
|
||||
return useMemo(
|
||||
() => ({
|
||||
volume: theme.volume,
|
||||
muted: theme.muted,
|
||||
setVolume: ctrl.setVolume,
|
||||
setMuted: ctrl.setMuted,
|
||||
}),
|
||||
[theme.volume, theme.muted, ctrl],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
export type SoundDef = {
|
||||
/** Absolute URL (or array for fallback formats). */
|
||||
src: string | string[];
|
||||
/** Optional sprite name within `src`. If omitted, plays the whole file. */
|
||||
sprite?: string;
|
||||
/** Sprite map declared once per asset; merged across SoundDefs that share `src`. */
|
||||
spriteMap?: Record<string, [number, number]>;
|
||||
/** 0..1, multiplied with the master volume. Defaults to 1. */
|
||||
volume?: number;
|
||||
/** Howler playbackRate. Defaults to 1. */
|
||||
rate?: number;
|
||||
/** Concurrent instances allowed. Defaults to 1. */
|
||||
pool?: number;
|
||||
/** Stop any in-flight instance before playing. Useful for hover sounds. */
|
||||
interrupt?: boolean;
|
||||
};
|
||||
|
||||
export type CoreSoundKey =
|
||||
| "click"
|
||||
| "hover"
|
||||
| "toggle"
|
||||
| "focus"
|
||||
| "selectOpen"
|
||||
| "selectClose"
|
||||
| "lobbyCreated"
|
||||
| "gameStart"
|
||||
| "gameWon"
|
||||
| "roundSuccess"
|
||||
| "userJoined"
|
||||
| "userLeft"
|
||||
| "error"
|
||||
| "submit"
|
||||
| "vote";
|
||||
|
||||
/** Games augment this via `declare module "@unom/ui/sound"` to register custom token names. */
|
||||
export interface SoundCustomTokens {}
|
||||
export type CustomSoundKey = Extract<keyof SoundCustomTokens, string>;
|
||||
|
||||
export type SoundTheme = {
|
||||
tokens: Record<CoreSoundKey, SoundDef | null>;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
custom: Record<string, SoundDef | null>;
|
||||
};
|
||||
|
||||
export type PartialSoundTheme = {
|
||||
tokens?: Partial<Record<CoreSoundKey, SoundDef | null>>;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
custom?: Record<string, SoundDef | null>;
|
||||
};
|
||||
|
||||
export type PlayOptions = {
|
||||
volume?: number;
|
||||
rate?: number;
|
||||
};
|
||||
|
||||
export type PlayUrlOptions = PlayOptions & {
|
||||
/** Stop any in-flight instance of this URL before playing. */
|
||||
stopOthers?: boolean;
|
||||
};
|
||||
|
||||
export type AnySoundKey = CoreSoundKey | CustomSoundKey;
|
||||
|
||||
export type SoundController = {
|
||||
resolve: (key: AnySoundKey | (string & {})) => SoundDef | null;
|
||||
play: (key: AnySoundKey | (string & {}), opts?: PlayOptions) => void;
|
||||
playUrl: (url: string, opts?: PlayUrlOptions) => void;
|
||||
stop: (key: AnySoundKey | (string & {})) => void;
|
||||
stopAll: () => void;
|
||||
setVolume: (v: number) => void;
|
||||
setMuted: (m: boolean) => void;
|
||||
readonly volume: number;
|
||||
readonly muted: boolean;
|
||||
};
|
||||
|
||||
/** Shared default volume for user-uploaded "win" sounds. */
|
||||
export const WIN_SOUND_VOLUME = 0.4;
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Transition, Variants } from "motion";
|
||||
|
||||
export const defaultTransitionCard: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 200,
|
||||
damping: 10,
|
||||
mass: 1,
|
||||
};
|
||||
|
||||
export const defaultAnimationStagger = {
|
||||
variants: {
|
||||
enter: {},
|
||||
from: {},
|
||||
},
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultVariantsCard: Variants = {
|
||||
enter: { y: 0, opacity: 1 },
|
||||
from: { opacity: 0, y: 30 },
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
@import "./timing-functions.css" layer(base);
|
||||
@import "./page-transition.css" layer(base);
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@source "./**/*.{js,mjs}";
|
||||
|
||||
:root {
|
||||
--main: oklch(0.26% 0 0);
|
||||
--accent: oklch(0.73 0.2 350.45);
|
||||
--primary: oklch(0.56 0.17 253.49);
|
||||
--brand: oklch(0.56 0.17 253.49);
|
||||
--neutral: oklch(0.9 0.07 346.02);
|
||||
--neutral-accent: oklch(0.92% 0.13 152.01);
|
||||
--neutral-highlight: oklch(0.7448% 0.1823 50.477);
|
||||
--highlight: oklch(0.7 0.23 352.23);
|
||||
--success: oklch(91.1% 0.1605 148.89);
|
||||
--error: oklch(65.7% 0.22923 26.238);
|
||||
|
||||
--font-display: "Integral CF", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono:
|
||||
Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
|
||||
--radius: 0.625rem;
|
||||
/* Floor for the nested-card border radius — see card.tsx. Each
|
||||
theme can tune this independently of the base `--radius` to
|
||||
keep deeply-nested cards visibly rounded. */
|
||||
--radius-card-min: var(--radius);
|
||||
}
|
||||
|
||||
@theme inline static {
|
||||
--color-main: var(--main);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary: var(--primary);
|
||||
--color-accent: var(--accent);
|
||||
--color-brand: var(--brand);
|
||||
--color-neutral: var(--neutral);
|
||||
--color-neutral-accent: var(--neutral-accent);
|
||||
--color-neutral-highlight: var(--neutral-highlight);
|
||||
--color-highlight: var(--highlight);
|
||||
--color-success: var(--success);
|
||||
--color-error: var(--error);
|
||||
|
||||
--font-display: var(--font-display);
|
||||
|
||||
--radius-card: calc(var(--radius) * 3);
|
||||
--radius-button: 9999px;
|
||||
--radius-main: calc(var(--radius) * 3);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 12px);
|
||||
}
|
||||
|
||||
@theme {
|
||||
--spacing-main: 15px;
|
||||
--spacing-card: 1.5rem;
|
||||
|
||||
--spacing-section-main-x: 25px;
|
||||
--spacing-section-main-y: 25px;
|
||||
|
||||
--spacing-input-height: 3rem;
|
||||
|
||||
--spacing-padding-card: 1.25rem;
|
||||
--spacing-height-header: 80px;
|
||||
|
||||
--container-max-section: 100%;
|
||||
|
||||
--animate-collapsible-down: collapsible-down 0.6s var(--ease-out-quart);
|
||||
--animate-collapsible-up: collapsible-up 0.6s var(--ease-out-quart);
|
||||
--animate-accordion-down: accordion-down 0.6s var(--ease-out-quart);
|
||||
--animate-accordion-up: accordion-up 0.6s var(--ease-out-quart);
|
||||
|
||||
@keyframes collapsible-down {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes collapsible-up {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@variant dark {
|
||||
:root {
|
||||
--main: oklch(1 0 0);
|
||||
--accent: oklch(0.83 0.12 346.59);
|
||||
--secondary: oklch(0.8 0 0);
|
||||
--brand: oklch(0.73 0.2 350.45);
|
||||
--primary: oklch(0.73 0.2 350.45);
|
||||
--neutral: oklch(0.2 0.03 245.99);
|
||||
--neutral-highlight: oklch(0.73 0.2 350.45);
|
||||
--neutral-accent: oklch(0.17 0.02 236.04);
|
||||
--highlight: oklch(66.39% 0.2398 3.2);
|
||||
--success: oklch(91.1% 0.1605 148.89);
|
||||
--error: oklch(67.36% 0.2339 0.92);
|
||||
}
|
||||
}
|
||||
|
||||
@variant lg {
|
||||
:root {
|
||||
--container-max-section: 1000px;
|
||||
|
||||
--spacing-height-header: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
@variant xl {
|
||||
:root {
|
||||
--container-max-section: 1500px;
|
||||
|
||||
--spacing-section-main-x: 100px;
|
||||
--spacing-section-main-y: 45px;
|
||||
--spacing-height-header: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply bg-neutral text-main font-sans;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
code {
|
||||
@apply font-mono;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-main font-display;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl font-semibold;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl font-medium;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
@apply decoration-main text-main;
|
||||
}
|
||||
|
||||
td {
|
||||
@apply px-3 py-1 border;
|
||||
}
|
||||
|
||||
article {
|
||||
& h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
@apply mt-6;
|
||||
}
|
||||
|
||||
& h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
p,
|
||||
ol,
|
||||
ul {
|
||||
@apply mb-4;
|
||||
}
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
@apply max-w-150 text-base text-secondary;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.richtext {
|
||||
& h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
p,
|
||||
ol,
|
||||
ul {
|
||||
@apply mb-4 mt-6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/* ============================================================================
|
||||
@unom/ui material system — skeuomorphic "jelly" surfaces.
|
||||
----------------------------------------------------------------------------
|
||||
OPT-IN: `@import "@unom/ui/styles/material.css"` in your globals.css AND
|
||||
mount <MaterialProvider> (from "@unom/ui/material"). The provider is OFF by
|
||||
default, so components only emit `.material` + `data-material` when a game
|
||||
opts in — games that don't are unaffected.
|
||||
|
||||
THEMING: colours derive from your palette via color-mix off one `--mat-base`
|
||||
(the components set it: solid buttons → --primary / --error, neutral surfaces
|
||||
→ --neutral / --neutral-accent). Override any `--mat-*` knob in your :root.
|
||||
|
||||
SAFARI: the specular is a blurred `.material-fx` layer where filter + clip-path
|
||||
live on the SAME element (children are plain) — the only Safari-safe way to
|
||||
clip a blur. Live blur has a WebKit partial-repaint bug under pointer movement,
|
||||
so components gate pointer tracking via isWebkit() (see use-specular.ts).
|
||||
============================================================================ */
|
||||
|
||||
@property --mx {
|
||||
syntax: "<percentage>";
|
||||
inherits: true;
|
||||
initial-value: 50%;
|
||||
}
|
||||
@property --my {
|
||||
syntax: "<percentage>";
|
||||
inherits: true;
|
||||
initial-value: 30%;
|
||||
}
|
||||
|
||||
:root {
|
||||
--mat-blur: 15px; /* specular softness */
|
||||
--mat-glow: 0.67; /* solid-preset specular intensity */
|
||||
--mat-glow-hover: 0.92; /* solid intensity on hover/focus */
|
||||
--mat-rim: 0.25; /* bottom Fresnel rim */
|
||||
--mat-sheen-opacity: 0.45; /* broad top sheen strength */
|
||||
--mat-spot-opacity: 0.9; /* tight hot-spot strength */
|
||||
--mat-blend: screen; /* specular compositing */
|
||||
--mat-spec-h: 320; /* specular hue */
|
||||
--mat-neutral-glow: 0.5; /* neutral-preset specular intensity */
|
||||
--mat-neutral-glow-hover: 0.72;
|
||||
--mat-card-glow: 0.28; /* cards are large → softer specular */
|
||||
--mat-card-glow-hover: 0.42;
|
||||
--mat-card-ring: var(
|
||||
--accent
|
||||
); /* cards keep an accent ring (was ring-accent) */
|
||||
--mat-card-tint: 50%; /* how far the card's shade side travels to accent */
|
||||
}
|
||||
|
||||
/* surface host: derived colours + stacking context for the fx layer */
|
||||
.material {
|
||||
--mat-base: var(--primary);
|
||||
--mat-lit: color-mix(in oklch, var(--mat-base), white 30%);
|
||||
--mat-shade: color-mix(in oklch, var(--mat-base), black 14%);
|
||||
--mat-bevel: color-mix(in oklch, var(--mat-base), white 72%);
|
||||
--mat-spec: oklch(0.97 0.03 var(--mat-spec-h));
|
||||
--mat-rimcol: oklch(0.99 0.03 var(--mat-spec-h) / var(--mat-rim));
|
||||
--mat-radius: 16px;
|
||||
/* No `position` here: fx-bearing components add `relative` themselves, so a
|
||||
positioned host (e.g. a `fixed` dialog) is never overridden. */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* The single blurred specular layer. z-index:-1 keeps it BEHIND the element's
|
||||
own content (so children need no wrapping) but ABOVE its background — the
|
||||
isolation:isolate above contains the negative index. filter + clip-path on the
|
||||
SAME element is the Safari-safe clip. */
|
||||
.material-fx {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
filter: blur(var(--mat-blur));
|
||||
clip-path: inset(0 round var(--mat-radius));
|
||||
mix-blend-mode: var(--mat-blend, screen);
|
||||
opacity: var(--mat-glow);
|
||||
transition: opacity 0.28s ease;
|
||||
/* Promote to a GPU-backed layer so WebKit rasterises the (static) blur once
|
||||
into a cached texture instead of re-blurring on the CPU; only opacity
|
||||
animates (hover), which is cheap on a composited layer. `will-change:
|
||||
filter` is intentionally NOT set — hinting the filter changes defeats that
|
||||
cache and is what pushed Safari onto the CPU blur path. */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
.material-fx > i {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.material:hover > .material-fx,
|
||||
.material:focus-visible > .material-fx {
|
||||
opacity: var(--mat-glow-hover);
|
||||
}
|
||||
|
||||
/* broad, mostly-static top sheen (soft environment reflection).
|
||||
Height is FIXED (not %) so it stays a top band on tall buttons instead of
|
||||
stretching down with the element; width still grows with the button. Cards
|
||||
override these with their own larger fixed band below. */
|
||||
.material-sheen {
|
||||
left: 50%;
|
||||
top: -28px;
|
||||
transform: translateX(-50%);
|
||||
width: 88%;
|
||||
height: 64px;
|
||||
background: var(--mat-spec);
|
||||
opacity: var(--mat-sheen-opacity);
|
||||
}
|
||||
/* tight specular hot-spot — tracks the pointer near the top */
|
||||
.material-spot {
|
||||
left: var(--mx, 50%);
|
||||
top: -8px;
|
||||
transform: translateX(-50%);
|
||||
width: 26%;
|
||||
height: 32px;
|
||||
background: #fff;
|
||||
opacity: var(--mat-spot-opacity);
|
||||
}
|
||||
|
||||
/* ── PRESET: solid (filled colour — default/destructive buttons, checkbox) ── */
|
||||
[data-material="solid"] {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--mat-lit) 0%,
|
||||
var(--mat-base) 50%,
|
||||
var(--mat-shade) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 2px 0 var(--mat-bevel),
|
||||
inset 0 0 0 1px oklch(0.99 0.03 var(--mat-spec-h) / 0.35),
|
||||
inset 0 -3px 3px var(--mat-rimcol),
|
||||
inset 0 -16px 22px -10px oklch(0 0 0 / 0.35),
|
||||
inset 0 8px 18px -6px oklch(0.99 0.05 var(--mat-spec-h) / 0.25),
|
||||
0 12px 26px -8px oklch(0 0 0 / 0.45),
|
||||
0 2px 5px oklch(0 0 0 / 0.3);
|
||||
text-shadow:
|
||||
0 1px 1px oklch(0 0 0 / 0.32),
|
||||
0 -0.5px 0 oklch(1 0 0 / 0.12);
|
||||
}
|
||||
|
||||
/* ── PRESET: neutral (cards + outline/secondary buttons; keep ring/border) ── */
|
||||
[data-material="neutral"] {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in oklch, var(--mat-base), white 10%) 0%,
|
||||
var(--mat-base) 55%,
|
||||
color-mix(in oklch, var(--mat-base), black 7%) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in oklch, var(--mat-base), white 60%),
|
||||
inset 0 -2px 3px var(--mat-rimcol),
|
||||
inset 0 -10px 16px -10px oklch(0 0 0 / 0.14);
|
||||
}
|
||||
[data-material="neutral"] > .material-fx {
|
||||
opacity: var(--mat-neutral-glow);
|
||||
}
|
||||
[data-material="neutral"]:hover > .material-fx,
|
||||
[data-material="neutral"]:focus-visible > .material-fx {
|
||||
opacity: var(--mat-neutral-glow-hover);
|
||||
}
|
||||
|
||||
/* Large surfaces (cards): the %-sized sheen/spot grow with the card while the
|
||||
blur stays fixed, so on big cards they turn into hard blobs. Pin them to a
|
||||
fixed-size, more-heavily-blurred top band so a card of any height reads as a
|
||||
soft top sheen rather than a washed-out half. */
|
||||
.material-card {
|
||||
/* Accent SHADING: the face stays a light neutral but shades into the accent
|
||||
toward the bottom, and the inner depth shadow is accent-coloured instead of
|
||||
grey — so the card reads as lit neutral with an accent-coloured shadow/ambient.
|
||||
Tinting the *whole* surface washes out (neutral ≈ accent hue here), so the
|
||||
accent lives on the shade side. --mat-card-tint = how far that shade travels. */
|
||||
--mat-card-shade: color-mix(
|
||||
in oklch,
|
||||
var(--mat-base),
|
||||
var(--accent) var(--mat-card-tint, 50%)
|
||||
);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklch, var(--mat-base), white 12%) 0%,
|
||||
var(--mat-base) 42%,
|
||||
var(--mat-card-shade) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 0 0 2px var(--mat-card-ring),
|
||||
inset 0 1px 0 color-mix(in oklch, var(--mat-base), white 55%),
|
||||
inset 0 -2px 3px var(--mat-rimcol),
|
||||
inset 0 -16px 26px -14px color-mix(in oklch, var(--accent), black 8%);
|
||||
}
|
||||
.material-card > .material-fx {
|
||||
filter: blur(calc(var(--mat-blur) * 1.8));
|
||||
opacity: var(--mat-card-glow);
|
||||
}
|
||||
.material-card:hover > .material-fx,
|
||||
.material-card:focus-visible > .material-fx {
|
||||
opacity: var(--mat-card-glow-hover);
|
||||
}
|
||||
.material-card .material-sheen {
|
||||
top: -56px;
|
||||
height: 200px;
|
||||
width: 92%;
|
||||
}
|
||||
.material-card .material-spot {
|
||||
top: -32px;
|
||||
height: 130px;
|
||||
width: 34%;
|
||||
}
|
||||
|
||||
/* ── INPUTS: recessed/carved surface, no fx layer (inputs are void elements) ── */
|
||||
.material-input {
|
||||
background: color-mix(in oklch, var(--neutral, oklch(0.95 0 0)), black 3%);
|
||||
box-shadow:
|
||||
inset 0 2px 4px oklch(0 0 0 / 0.12),
|
||||
inset 0 1px 1px oklch(0 0 0 / 0.08),
|
||||
0 1px 0 color-mix(in oklch, var(--neutral, oklch(1 0 0)), white 40%);
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
.material-input:focus,
|
||||
.material-input:focus-visible {
|
||||
box-shadow:
|
||||
inset 0 2px 4px oklch(0 0 0 / 0.1),
|
||||
0 0 0 3px
|
||||
color-mix(in oklch, var(--primary, oklch(0.6 0.15 250)), transparent 62%);
|
||||
}
|
||||
|
||||
/* ── small / stateful controls: surface only, no fx layer ──────────────────
|
||||
These derive directly from the palette (no --mat-base needed) and only paint
|
||||
their surface in the relevant state, so unchecked/inactive controls stay flat. */
|
||||
|
||||
/* checkbox — glossy fill only when checked */
|
||||
.material-check[data-state="checked"],
|
||||
.material-check[data-state="indeterminate"] {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in oklch, var(--primary), white 30%) 0%,
|
||||
var(--primary) 55%,
|
||||
color-mix(in oklch, var(--primary), black 14%) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in oklch, var(--primary), white 60%),
|
||||
inset 0 -1px 2px oklch(0 0 0 / 0.3),
|
||||
0 1px 2px oklch(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
/* tabs + toggle-group — recessed list, raised active item. Tabs mark the active
|
||||
trigger with data-state="active"; radix ToggleGroup uses data-state="on". */
|
||||
.material-tablist {
|
||||
box-shadow:
|
||||
inset 0 1px 3px oklch(0 0 0 / 0.12),
|
||||
inset 0 -1px 0 color-mix(in oklch, var(--neutral-accent), white 25%);
|
||||
}
|
||||
.material-tab[data-state="active"],
|
||||
.material-tab[data-state="on"] {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in oklch, var(--neutral), white 14%) 0%,
|
||||
var(--neutral) 60%,
|
||||
color-mix(in oklch, var(--neutral), black 6%) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in oklch, var(--neutral), white 70%),
|
||||
inset 0 -1px 2px oklch(0 0 0 / 0.12),
|
||||
0 1px 2px oklch(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
/* toast — glossy neutral panel (class-based; Sonner is styled via classNames) */
|
||||
.material-toast {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in oklch, var(--neutral), white 10%) 0%,
|
||||
var(--neutral) 55%,
|
||||
color-mix(in oklch, var(--neutral), black 7%) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in oklch, var(--neutral), white 55%),
|
||||
inset 0 -2px 3px
|
||||
oklch(0.99 0.03 var(--mat-spec-h, 320) / var(--mat-rim, 0.25)),
|
||||
0 12px 26px -8px oklch(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* elastic slider material: a carved groove track + a glossy primary fill */
|
||||
.material-slider-track {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklch, var(--neutral), black 9%),
|
||||
color-mix(in oklch, var(--neutral), black 2%)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 2px 5px oklch(0 0 0 / 0.2),
|
||||
inset 0 1px 1px oklch(0 0 0 / 0.12),
|
||||
inset 0 -1px 0 color-mix(in oklch, var(--neutral), white 35%);
|
||||
}
|
||||
.material-slider-fill {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in oklch, var(--primary), white 32%) 0%,
|
||||
var(--primary) 55%,
|
||||
color-mix(in oklch, var(--primary), black 12%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.material-spot {
|
||||
left: 50% !important;
|
||||
top: -18% !important;
|
||||
}
|
||||
.material-fx,
|
||||
.material-input {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
@keyframes pageEnter {
|
||||
from {
|
||||
transform: translateY(40px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes pageLeave {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Default theme for @unom/ui's own Storybook (and other isolated
|
||||
* environments). Consumers ship their own globals.css with their palette
|
||||
* + tokens — this file exists only so components have a working theme
|
||||
* when rendered in isolation here.
|
||||
*
|
||||
* Consumers should NOT import this file.
|
||||
*/
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@source "../**/*.{ts,tsx,js,mjs}";
|
||||
|
||||
:root {
|
||||
--main: oklch(0.2 0 0);
|
||||
--secondary: oklch(0.4 0 0);
|
||||
--accent: oklch(0.6 0.15 250);
|
||||
--primary: oklch(0.6 0.15 250);
|
||||
--brand: oklch(0.6 0.15 250);
|
||||
--neutral: oklch(1 0 0);
|
||||
--neutral-accent: oklch(0.96 0 0);
|
||||
--neutral-highlight: oklch(0.9 0 0);
|
||||
--highlight: oklch(0.6 0.15 250);
|
||||
--success: oklch(91.1% 0.1605 148.89);
|
||||
--error: oklch(65.7% 0.22923 26.238);
|
||||
|
||||
--font-display: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Courier New", monospace;
|
||||
|
||||
--radius: 0.625rem;
|
||||
/* Floor for the nested-card border radius — see card.tsx. Each
|
||||
theme can tune this independently of the base `--radius` to
|
||||
keep deeply-nested cards visibly rounded. */
|
||||
--radius-card-min: var(--radius);
|
||||
}
|
||||
|
||||
@theme inline static {
|
||||
--color-main: var(--main);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary: var(--primary);
|
||||
--color-accent: var(--accent);
|
||||
--color-brand: var(--brand);
|
||||
--color-neutral: var(--neutral);
|
||||
--color-neutral-accent: var(--neutral-accent);
|
||||
--color-neutral-highlight: var(--neutral-highlight);
|
||||
--color-highlight: var(--highlight);
|
||||
--color-success: var(--success);
|
||||
--color-error: var(--error);
|
||||
|
||||
--font-display: var(--font-display);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-serif: var(--font-serif);
|
||||
--font-mono: var(--font-mono);
|
||||
|
||||
--radius-card: calc(var(--radius) * 3);
|
||||
--radius-button: 9999px;
|
||||
--radius-main: calc(var(--radius) * 3);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 12px);
|
||||
}
|
||||
|
||||
@theme {
|
||||
--spacing-main: 15px;
|
||||
--spacing-card: 1.5rem;
|
||||
--spacing-input-height: 3rem;
|
||||
--spacing-padding-card: 1.25rem;
|
||||
--spacing-height-header: 80px;
|
||||
--container-max-section: 100%;
|
||||
|
||||
--animate-collapsible-down: collapsible-down 0.6s ease-out;
|
||||
--animate-collapsible-up: collapsible-up 0.6s ease-out;
|
||||
--animate-accordion-down: accordion-down 0.6s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.6s ease-out;
|
||||
|
||||
@keyframes collapsible-down {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes collapsible-up {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--neutral);
|
||||
color: var(--main);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@theme {
|
||||
--ease-in-sine: cubic-bezier(0.47, 0, 0.745, 0.715);
|
||||
--ease-out-sine: cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
--ease-in-out-sine: cubic-bezier(0.445, 0.05, 0.55, 0.95);
|
||||
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
|
||||
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
|
||||
--ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
|
||||
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
--ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22);
|
||||
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
|
||||
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
|
||||
--ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
|
||||
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
|
||||
--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
|
||||
--ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335);
|
||||
--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
|
||||
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs as TabsPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMaterial } from "@/material/provider";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile-first default: when triggers don't fit, the list scrolls
|
||||
// horizontally instead of wrapping. Wrapping made the background pill
|
||||
// (bg-neutral-accent) lag behind the second row and looked broken. Set
|
||||
// `w-full` plus `[&>button]:flex-1` on the list itself if you want the
|
||||
// desktop "equal-width" layout — the new defaults are scroll-friendly.
|
||||
//
|
||||
// Scrollbar is hidden so the row feels like a native tab strip; users
|
||||
// know they can swipe because the active tab scrolls into view (Radix
|
||||
// does this automatically) and adjacent triggers stay partially visible
|
||||
// at the edge.
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
const mat = useMaterial("tabs");
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-neutral-accent text-secondary flex h-input-height w-fit max-w-full items-center justify-start rounded-lg p-[3px] overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
mat.enabled && "material-tablist",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
const mat = useMaterial("tabs");
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-neutral data-[state=active]:text-main data-[state=active]:shadow-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] inline-flex h-[calc(var(--spacing-input-height)-6px)] shrink-0 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:opacity-50",
|
||||
mat.enabled && "material-tab",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn(
|
||||
"flex-1 outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px] rounded-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import Text from "./text";
|
||||
|
||||
const meta: Meta<typeof Text> = {
|
||||
title: "text/Text",
|
||||
component: Text,
|
||||
argTypes: {
|
||||
tag: {
|
||||
control: "select",
|
||||
options: ["h1", "h2", "h3", "h4", "h5", "p", "span"],
|
||||
},
|
||||
color: {
|
||||
control: "select",
|
||||
options: ["default", "main", "secondary", "neutral"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Text>;
|
||||
|
||||
export const Heading: Story = {
|
||||
args: { tag: "h1", children: "The quick brown fox" },
|
||||
};
|
||||
|
||||
export const Paragraph: Story = {
|
||||
args: {
|
||||
tag: "p",
|
||||
children:
|
||||
"Paragraph text — soft secondary color by default in this design system.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Scale: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text tag="h1">Heading 1</Text>
|
||||
<Text tag="h2">Heading 2</Text>
|
||||
<Text tag="h3">Heading 3</Text>
|
||||
<Text tag="h4">Heading 4</Text>
|
||||
<Text tag="p">Body paragraph</Text>
|
||||
<Text tag="span">Inline span</Text>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { type HTMLMotionProps, motion } from "motion/react";
|
||||
import { type FC, forwardRef, type HTMLProps } from "react";
|
||||
import { z } from "zod";
|
||||
import { useAnimation } from "@/animation/provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TextTagHeadingOptions = ["h1", "h2", "h3", "h4", "h5"] as const;
|
||||
const TextTagContentOptions = ["span", "p"] as const;
|
||||
|
||||
export const TextTag = z.enum([
|
||||
...TextTagHeadingOptions,
|
||||
...TextTagContentOptions,
|
||||
]);
|
||||
|
||||
const textVariants = cva(null, {
|
||||
variants: {
|
||||
color: {
|
||||
default: "",
|
||||
main: "",
|
||||
secondary: "text-secondary",
|
||||
neutral: "text-neutral",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type Tags = z.output<typeof TextTag>;
|
||||
|
||||
type TextProps = {
|
||||
tag: Tags;
|
||||
} & HTMLProps<HTMLHeadingElement> &
|
||||
VariantProps<typeof textVariants>;
|
||||
|
||||
const Text = forwardRef<HTMLHeadingElement, TextProps>(
|
||||
({ tag, color, className, ...props }, ref) => {
|
||||
const Component = tag;
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={cn(textVariants({ color, className }))}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</Component>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Text.displayName = "Text";
|
||||
|
||||
const MText = motion.create(Text);
|
||||
|
||||
const AnimatedText: FC<TextProps & HTMLMotionProps<Tags>> = ({
|
||||
variants,
|
||||
transition,
|
||||
...props
|
||||
}) => {
|
||||
const token = useAnimation("text");
|
||||
return (
|
||||
<MText
|
||||
variants={variants ?? token.variants}
|
||||
transition={transition ?? token.transition}
|
||||
ref={props.ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedText;
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "./button";
|
||||
import { toast, Toaster } from "./toast";
|
||||
|
||||
const meta: Meta<typeof Toaster> = {
|
||||
title: "toast/Toast",
|
||||
component: Toaster,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Toaster>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Toaster />
|
||||
<Button onClick={() => toast("Hello from a toast")}>Message</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => toast.success("Saved successfully")}
|
||||
>
|
||||
Success
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => toast.error("Something went wrong")}
|
||||
>
|
||||
Error
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => toast.info("Heads up")}>
|
||||
Info
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => toast.warning("Careful now")}>
|
||||
Warning
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
render: () => (
|
||||
<>
|
||||
<Toaster />
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast("Event created", {
|
||||
description: "Friday, March 21st at 1:57pm",
|
||||
})
|
||||
}
|
||||
>
|
||||
Show toast
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithAction: Story = {
|
||||
render: () => (
|
||||
<>
|
||||
<Toaster />
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast("File moved to trash", {
|
||||
action: {
|
||||
label: "Undo",
|
||||
onClick: () => toast.success("Restored"),
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Move to trash
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export const Promise: Story = {
|
||||
render: () => (
|
||||
<>
|
||||
<Toaster />
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast.promise(
|
||||
new globalThis.Promise<{ name: string }>((resolve) =>
|
||||
setTimeout(() => resolve({ name: "report.pdf" }), 1500),
|
||||
),
|
||||
{
|
||||
loading: "Uploading...",
|
||||
success: (data) =>
|
||||
`${(data as { name: string }).name} uploaded`,
|
||||
error: "Upload failed",
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
import type { ComponentProps, FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMaterial } from "@/material/provider";
|
||||
|
||||
// `sonner` runs `React.createElement` at module top-level, so we load it
|
||||
// dynamically and gate on `window` to keep this module safe to evaluate
|
||||
// during SSR. Letting Vite/Rollup chunk the dynamic import normally
|
||||
// produces a resolvable URL in the client bundle.
|
||||
type SonnerModule = typeof import("sonner");
|
||||
|
||||
let cached: SonnerModule | null = null;
|
||||
let pending: Promise<SonnerModule> | null = null;
|
||||
|
||||
function ensureSonner(): Promise<SonnerModule> | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (!pending) {
|
||||
pending = import("sonner").then((m: SonnerModule) => {
|
||||
cached = m;
|
||||
return m;
|
||||
});
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
const Toaster: FC<ComponentProps<SonnerModule["Toaster"]>> = (props) => {
|
||||
const [mod, setMod] = useState<SonnerModule | null>(cached);
|
||||
const mat = useMaterial("toast");
|
||||
useEffect(() => {
|
||||
if (mod) return;
|
||||
const p = ensureSonner();
|
||||
if (!p) return;
|
||||
let active = true;
|
||||
p.then((m) => {
|
||||
if (active) setMod(m);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [mod]);
|
||||
|
||||
if (!mod) return null;
|
||||
return (
|
||||
<mod.Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: `group rounded-card border bg-neutral text-main shadow-lg gap-3 p-4 backdrop-blur-sm${mat.enabled ? " material-toast" : ""}`,
|
||||
title: "font-semibold text-main",
|
||||
description: "text-main/70 text-sm",
|
||||
actionButton: "rounded-button bg-primary text-neutral px-3 py-1",
|
||||
cancelButton: "rounded-button bg-neutral-accent text-main px-3 py-1",
|
||||
icon: "text-accent",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type ToastApi = SonnerModule["toast"];
|
||||
type ToastFn = ((...args: Parameters<ToastApi>) => void) & {
|
||||
[K in keyof ToastApi]: ToastApi[K] extends (...args: infer A) => unknown
|
||||
? (...args: A) => void
|
||||
: ToastApi[K];
|
||||
};
|
||||
|
||||
function invoke(method: string | null, args: unknown[]): void {
|
||||
const p = ensureSonner();
|
||||
if (!p) return;
|
||||
void p.then((m) => {
|
||||
const target =
|
||||
method === null ? m.toast : (m.toast as unknown as Record<string, unknown>)[method];
|
||||
if (typeof target === "function") {
|
||||
(target as (...a: unknown[]) => unknown)(...args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const toastImpl = ((...args: unknown[]) => invoke(null, args)) as unknown as ToastFn;
|
||||
for (const method of [
|
||||
"success",
|
||||
"error",
|
||||
"info",
|
||||
"warning",
|
||||
"message",
|
||||
"loading",
|
||||
"dismiss",
|
||||
"custom",
|
||||
"promise",
|
||||
] as const) {
|
||||
(toastImpl as unknown as Record<string, unknown>)[method] = (...args: unknown[]) =>
|
||||
invoke(method, args);
|
||||
}
|
||||
|
||||
const toast = toastImpl;
|
||||
|
||||
export { Toaster, toast };
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMaterial } from "@/material/provider";
|
||||
|
||||
/**
|
||||
* shadcn-style single/multi ToggleGroup (radix), themed with the unom tokens:
|
||||
* a recessed `--neutral-accent` track with a raised `--neutral` active item —
|
||||
* matching the Tabs strip. When the game opts into the material it reuses the
|
||||
* Tabs material classes (`material-tablist` / `material-tab`). Use for
|
||||
* mutually-exclusive segmented filters / view switches.
|
||||
*/
|
||||
function ToggleGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root>) {
|
||||
const mat = useMaterial("tabs");
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
className={cn(
|
||||
"inline-flex h-input-height w-fit items-center gap-0.5 rounded-lg bg-neutral-accent p-[3px] text-secondary",
|
||||
mat.enabled
|
||||
? "material-tablist"
|
||||
: "shadow-[inset_0_1px_3px_oklch(0_0_0/0.12)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item>) {
|
||||
const mat = useMaterial("tabs");
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
className={cn(
|
||||
"inline-flex h-[calc(var(--spacing-input-height)-6px)] shrink-0 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium whitespace-nowrap outline-none transition-[color,background-color,box-shadow]",
|
||||
"hover:text-main focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"data-[state=on]:bg-neutral data-[state=on]:text-main data-[state=on]:shadow-sm",
|
||||
mat.enabled && "material-tab",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useWindowVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props<T> = {
|
||||
items: T[];
|
||||
columns: number;
|
||||
estimateRowHeight: number;
|
||||
renderItem: (item: T, index: number) => ReactNode;
|
||||
getItemKey: (item: T, index: number) => string;
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
overscan?: number;
|
||||
className?: string;
|
||||
rowClassName?: string;
|
||||
rowGap?: string;
|
||||
loadingRow?: ReactNode;
|
||||
};
|
||||
|
||||
export function VirtualizedGrid<T>({
|
||||
items,
|
||||
columns,
|
||||
estimateRowHeight,
|
||||
renderItem,
|
||||
getItemKey,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
overscan = 3,
|
||||
className,
|
||||
rowClassName,
|
||||
rowGap = "var(--spacing-card)",
|
||||
loadingRow,
|
||||
}: Props<T>) {
|
||||
const parentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const update = () => {
|
||||
if (!parentRef.current) return;
|
||||
const rect = parentRef.current.getBoundingClientRect();
|
||||
setScrollMargin(rect.top + window.scrollY);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
return () => window.removeEventListener("resize", update);
|
||||
}, []);
|
||||
|
||||
const rowCount = Math.ceil(items.length / columns);
|
||||
const totalCount = rowCount + (hasNextPage ? 1 : 0);
|
||||
|
||||
const rowKeys = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
for (let row = 0; row < rowCount; row++) {
|
||||
const first = items[row * columns];
|
||||
keys.push(first ? getItemKey(first, row * columns) : `row-${row}`);
|
||||
}
|
||||
return keys;
|
||||
}, [items, rowCount, columns, getItemKey]);
|
||||
|
||||
const virtualizer = useWindowVirtualizer({
|
||||
count: totalCount,
|
||||
estimateSize: () => estimateRowHeight,
|
||||
overscan,
|
||||
scrollMargin,
|
||||
getItemKey: (index) =>
|
||||
index < rowCount
|
||||
? (rowKeys[index] ?? `row-${index}`)
|
||||
: `sentinel-${index}`,
|
||||
});
|
||||
|
||||
const virtualRows = virtualizer.getVirtualItems();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage || !onLoadMore || isFetchingNextPage) return;
|
||||
const last = virtualRows[virtualRows.length - 1];
|
||||
if (last && last.index >= rowCount) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [virtualRows, rowCount, hasNextPage, isFetchingNextPage, onLoadMore]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={className}
|
||||
style={{ height: virtualizer.getTotalSize(), position: "relative" }}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const isSentinel = virtualRow.index >= rowCount;
|
||||
const rowItems = isSentinel
|
||||
? []
|
||||
: items.slice(
|
||||
virtualRow.index * columns,
|
||||
virtualRow.index * columns + columns,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start - virtualizer.options.scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{isSentinel ? (
|
||||
loadingRow
|
||||
) : (
|
||||
<div
|
||||
className={cn("grid", rowClassName)}
|
||||
style={{ paddingBottom: rowGap }}
|
||||
>
|
||||
{rowItems.map((item, i) => {
|
||||
const absoluteIndex = virtualRow.index * columns + i;
|
||||
return (
|
||||
<div key={getItemKey(item, absoluteIndex)}>
|
||||
{renderItem(item, absoluteIndex)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"types": ["bun"],
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
fixedExtension: false,
|
||||
entry: [
|
||||
"./src/**/*.(ts|tsx)",
|
||||
"!./src/**/*.stories.(ts|tsx)",
|
||||
],
|
||||
format: "esm",
|
||||
copy: [
|
||||
{
|
||||
from: "./src/styles/*.css",
|
||||
to: "./dist",
|
||||
},
|
||||
{
|
||||
from: "./src/assets",
|
||||
to: "./dist/",
|
||||
},
|
||||
],
|
||||
platform: "browser",
|
||||
dts: true,
|
||||
exports: false,
|
||||
deps: {
|
||||
neverBundle: [
|
||||
/\.mp3$/,
|
||||
/\.wav$/,
|
||||
/^react$/,
|
||||
/^react\//,
|
||||
/^react-dom$/,
|
||||
/^react-dom\//,
|
||||
],
|
||||
},
|
||||
plugins: [],
|
||||
});
|
||||
Reference in New Issue
Block a user