diff --git a/.storybook/fonts.css b/.storybook/fonts.css new file mode 100644 index 0000000..8cc0b88 --- /dev/null +++ b/.storybook/fonts.css @@ -0,0 +1,7 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); + +:root { + --font-inter: + "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji"; +} diff --git a/.storybook/main.js b/.storybook/main.js index d16ff77..1bd4231 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -10,7 +10,7 @@ const config = { "@storybook/addon-a11y", ], framework: { - name: "@storybook/nextjs-vite", + name: "@storybook/nextjs", options: {}, }, staticDirs: ["../public"], diff --git a/.storybook/preview.js b/.storybook/preview.js index 0b1f78a..aadfe40 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,4 +1,5 @@ import "../app/globals.css"; +import "./fonts.css"; /** @type { import('@storybook/react').Preview } */ const preview = { @@ -12,7 +13,7 @@ const preview = { }, decorators: [ (Story) => ( -
+
), diff --git a/app/components/Checkbox.js b/app/components/Checkbox.js new file mode 100644 index 0000000..a395c13 --- /dev/null +++ b/app/components/Checkbox.js @@ -0,0 +1,168 @@ +"use client"; + +import React, { memo, useId } from "react"; + +/** + * Checkbox + * A basic controlled checkbox with visual modes and interaction states. + * This is a minimal first pass; visuals will be refined collaboratively. + */ +const Checkbox = memo( + ({ + checked = false, + mode = "standard", // "standard" | "inverse" + state = "default", // "default" | "hover" | "focus" + disabled = false, + label, + className = "", + onChange, + id, + name, + value, + ariaLabel, + ...props + }) => { + const isInverse = mode === "inverse"; + + // Base tokens (rough placeholders leveraging existing CSS variables) + const colorSurface = isInverse + ? "var(--color-surface-inverse-primary)" + : "var(--color-surface-default-primary)"; + const colorContent = isInverse + ? "var(--color-content-inverse-primary)" + : "var(--color-content-default-primary)"; + const colorBrand = isInverse + ? "var(--color-content-inverse-brand-primary)" + : "var(--color-content-default-brand-primary)"; + + // Visual container depending on state + const baseBox = `flex items-center justify-center shrink-0 w-[var(--measures-sizing-024)] h-[var(--measures-sizing-024)] rounded-[var(--measures-radius-medium)] transition-all duration-200 ease-in-out`; + + const stateStyles = { + default: "", + hover: "", + focus: "", + }; + + // Background behavior: + // - Standard: background does not change on check; only checkmark appears + // - Inverse: transparent background, checkmark appears on check + const backgroundWhenChecked = isInverse + ? "var(--color-surface-default-transparent)" + : "var(--color-surface-default-primary)"; + const checkGlyphColor = checked + ? isInverse + ? "var(--color-content-inverse-primary)" + : "var(--color-border-default-brand-primary)" + : "transparent"; + const labelColor = colorContent; + + const combinedBoxStyles = `${baseBox} ${stateStyles[state]}`; + + // Force visible outline for standard / default / unchecked + // Outline classes instead of inline styles so hover can override + const defaultOutlineClass = isInverse + ? "outline outline-1 outline-[var(--color-border-inverse-primary)]" + : "outline outline-1 outline-[var(--color-border-default-tertiary)]"; + + // Apply brand outline only on actual :hover, and only when standard/unchecked + const conditionalHoverOutlineClass = + "hover:outline hover:outline-1 hover:outline-[var(--color-border-default-brand-primary)]"; + + // Focus state for standard/unchecked with brand primary color and specific blur/spread + const conditionalFocusClass = + "focus:outline focus:outline-1 focus:outline-[var(--color-border-default-utility-info)] focus:shadow-[0_0_10px_1px_var(--color-surface-inverse-brand-primary)]"; + + const handleToggle = (e) => { + if (disabled) return; + onChange?.({ + checked: !checked, + value, + event: e, + }); + }; + + // Generate unique ID for accessibility if not provided + const generatedId = useId(); + const checkboxId = id || `checkbox-${generatedId}`; + + const accessibilityProps = { + role: "checkbox", + "aria-checked": checked ? "true" : "false", + ...(disabled && { "aria-disabled": "true", tabIndex: -1 }), + ...(!disabled && { tabIndex: 0 }), + ...(ariaLabel && { "aria-label": ariaLabel }), + ...(label && !ariaLabel && { "aria-labelledby": `${checkboxId}-label` }), + id: checkboxId, + ...props, + }; + + return ( + + ); + }, +); + +Checkbox.displayName = "Checkbox"; + +export default Checkbox; diff --git a/app/components/ContextMenu.js b/app/components/ContextMenu.js new file mode 100644 index 0000000..1498910 --- /dev/null +++ b/app/components/ContextMenu.js @@ -0,0 +1,36 @@ +"use client"; + +import React, { forwardRef, memo } from "react"; + +const ContextMenu = forwardRef( + ({ className = "", children, ...props }, ref) => { + const menuClasses = ` + bg-black + border border-[var(--color-border-default-tertiary)] + rounded-[var(--measures-radius-medium)] + shadow-lg + p-[4px] + min-w-[200px] + max-w-[300px] + ${className} + ` + .trim() + .replace(/\s+/g, " "); + + return ( +
+ {children} +
+ ); + }, +); + +ContextMenu.displayName = "ContextMenu"; + +export default memo(ContextMenu); diff --git a/app/components/ContextMenuDivider.js b/app/components/ContextMenuDivider.js new file mode 100644 index 0000000..9eb2d32 --- /dev/null +++ b/app/components/ContextMenuDivider.js @@ -0,0 +1,21 @@ +"use client"; + +import React, { forwardRef, memo } from "react"; + +const ContextMenuDivider = forwardRef(({ className = "", ...props }, ref) => { + const dividerClasses = ` + border-t border-[var(--color-border-default-tertiary)] + my-1 + ${className} + ` + .trim() + .replace(/\s+/g, " "); + + return ( +
+ ); +}); + +ContextMenuDivider.displayName = "ContextMenuDivider"; + +export default memo(ContextMenuDivider); diff --git a/app/components/ContextMenuItem.js b/app/components/ContextMenuItem.js new file mode 100644 index 0000000..b84d462 --- /dev/null +++ b/app/components/ContextMenuItem.js @@ -0,0 +1,127 @@ +"use client"; + +import React, { forwardRef, memo, useCallback } from "react"; + +const ContextMenuItem = forwardRef( + ( + { + children, + selected = false, + hasSubmenu = false, + disabled = false, + className = "", + onClick, + size = "medium", + ...props + }, + ref, + ) => { + const getTextSize = () => { + switch (size) { + case "small": + return "text-[10px] leading-[14px]"; + case "medium": + return "text-[14px] leading-[20px]"; + case "large": + return "text-[16px] leading-[24px]"; + default: + return "text-[14px] leading-[20px]"; + } + }; + + const itemClasses = ` + flex items-center justify-between + px-[8px] py-[4px] + text-[var(--color-content-default-brand-primary)] + ${getTextSize()} + cursor-pointer + transition-colors duration-150 + ${ + selected + ? "bg-[var(--color-surface-default-secondary)] rounded-[var(--measures-radius-small)]" + : "" + } + ${ + disabled + ? "opacity-50 cursor-not-allowed" + : "hover:!bg-[var(--color-surface-default-secondary)] hover:!rounded-[var(--measures-radius-small)]" + } + ${className} + ` + .trim() + .replace(/\s+/g, " "); + + const handleClick = useCallback( + (e) => { + if (!disabled && onClick) { + onClick(e); + } + }, + [disabled, onClick], + ); + + const handleKeyDown = useCallback( + (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (!disabled && onClick) { + onClick(e); + } + } + }, + [disabled, onClick], + ); + + return ( +
+
+ {selected && ( + + + + )} + {children} +
+ {hasSubmenu && ( + + + + )} +
+ ); + }, +); + +ContextMenuItem.displayName = "ContextMenuItem"; + +export default memo(ContextMenuItem); diff --git a/app/components/ContextMenuSection.js b/app/components/ContextMenuSection.js new file mode 100644 index 0000000..2592ae3 --- /dev/null +++ b/app/components/ContextMenuSection.js @@ -0,0 +1,30 @@ +"use client"; + +import React, { forwardRef, memo } from "react"; + +const ContextMenuSection = forwardRef( + ({ title, children, className = "", ...props }, ref) => { + const sectionClasses = ` + ${className} + ` + .trim() + .replace(/\s+/g, " "); + + return ( +
+ {title && ( +
+
+ {title} +
+
+ )} + {children} +
+ ); + }, +); + +ContextMenuSection.displayName = "ContextMenuSection"; + +export default memo(ContextMenuSection); diff --git a/app/components/Input.js b/app/components/Input.js new file mode 100644 index 0000000..d86af2f --- /dev/null +++ b/app/components/Input.js @@ -0,0 +1,185 @@ +"use client"; + +import React, { memo, useCallback, forwardRef, useId } from "react"; + +const Input = forwardRef( + ( + { + size = "medium", + labelVariant = "default", + state = "default", + disabled = false, + error = false, + label, + placeholder, + value, + onChange, + onFocus, + onBlur, + id, + name, + type = "text", + className = "", + ...props + }, + ref, + ) => { + // Generate unique ID for accessibility if not provided + const generatedId = useId(); + const inputId = id || `input-${generatedId}`; + + // Size variants + const sizeStyles = { + small: { + input: + labelVariant === "horizontal" + ? "h-[30px] px-[12px] py-[8px] text-[10px]" + : "h-[32px] px-[12px] py-[8px] text-[10px]", + label: "text-[12px] leading-[14px] font-medium", + container: "gap-[4px]", + radius: "var(--measures-radius-small)", + }, + medium: { + input: "h-[36px] px-[12px] py-[8px] text-[14px] leading-[20px]", + label: "text-[14px] leading-[16px] font-medium", + container: "gap-[8px]", + radius: "var(--measures-radius-medium)", + }, + large: { + input: "h-[40px] px-[12px] py-[8px] text-[16px] leading-[24px]", + label: "text-[16px] leading-[20px] font-medium", + container: "gap-[12px]", + radius: "var(--measures-radius-large)", + }, + }; + + // State styles + const getStateStyles = () => { + if (disabled) { + return { + input: + "bg-[var(--color-content-default-secondary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] cursor-not-allowed", + label: "text-[var(--color-content-default-secondary)]", + }; + } + + if (error) { + return { + input: + "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-utility-negative)]", + label: "text-[var(--color-content-default-secondary)]", + }; + } + + switch (state) { + case "active": + return { + input: + "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)]", + label: "text-[var(--color-content-default-secondary)]", + }; + case "hover": + return { + input: + "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] shadow-[0_0_0_2px_var(--color-border-default-tertiary)]", + label: "text-[var(--color-content-default-secondary)]", + }; + case "focus": + return { + input: + "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-utility-info)] shadow-[0_0_5px_3px_#3281F8]", + label: "text-[var(--color-content-default-secondary)]", + }; + default: + return { + input: + "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] hover:shadow-[0_0_0_2px_var(--color-border-default-tertiary)]", + label: "text-[var(--color-content-default-secondary)]", + }; + } + }; + + const stateStyles = getStateStyles(); + const currentSize = sizeStyles[size]; + + // Container classes based on label variant + const containerClasses = + labelVariant === "horizontal" + ? `flex items-center gap-[12px]` + : `flex flex-col ${currentSize.container}`; + + const labelClasses = + labelVariant === "horizontal" + ? `${currentSize.label} font-inter min-w-fit` + : `${currentSize.label} font-inter`; + + const inputClasses = ` + w-full border transition-all duration-200 ease-in-out + focus:outline-none focus:ring-0 + ${currentSize.input} + ${stateStyles.input} + ${className} + `.trim(); + + const handleChange = useCallback( + (e) => { + if (!disabled && onChange) { + onChange(e); + } + }, + [disabled, onChange], + ); + + const handleFocus = useCallback( + (e) => { + if (!disabled && onFocus) { + onFocus(e); + } + }, + [disabled, onFocus], + ); + + const handleBlur = useCallback( + (e) => { + if (!disabled && onBlur) { + onBlur(e); + } + }, + [disabled, onBlur], + ); + + return ( +
+ {label && ( + + )} +
+ +
+
+ ); + }, +); + +Input.displayName = "Input"; + +export default memo(Input); diff --git a/app/components/RadioButton.js b/app/components/RadioButton.js new file mode 100644 index 0000000..fb313bf --- /dev/null +++ b/app/components/RadioButton.js @@ -0,0 +1,149 @@ +"use client"; + +import React, { memo, useCallback, useId } from "react"; + +const RadioButton = ({ + checked = false, + mode = "standard", + state = "default", + disabled = false, + label, + onChange, + id, + name, + value, + ariaLabel, + className = "", + ...props +}) => { + const isInverse = mode === "inverse"; + + // Base tokens (using same design tokens as Checkbox) + const colorSurface = isInverse + ? "var(--color-surface-inverse-primary)" + : "var(--color-surface-default-primary)"; + const colorContent = isInverse + ? "var(--color-content-inverse-primary)" + : "var(--color-content-default-primary)"; + const colorBrand = isInverse + ? "var(--color-content-inverse-brand-primary)" + : "var(--color-content-default-brand-primary)"; + + // Visual container depending on state + const baseBox = `flex items-center justify-center shrink-0 w-[var(--measures-sizing-024)] h-[var(--measures-sizing-024)] rounded-[var(--measures-radius-medium)] transition-all duration-200 ease-in-out`; + + const stateStyles = { + default: "", + hover: "", + focus: "", + }; + + // Background behavior: + // - Standard: background does not change on check; only dot appears + // - Inverse: transparent background, dot appears on check + const backgroundWhenChecked = isInverse + ? "var(--color-surface-default-transparent)" + : "var(--color-surface-default-primary)"; + + // Dot color for selected state + const dotColor = checked + ? isInverse + ? "var(--color-content-inverse-primary)" + : "var(--color-border-default-brand-primary)" + : "transparent"; + const labelColor = colorContent; + + const combinedBoxStyles = `${baseBox} ${stateStyles[state]}`; + + // Force visible outline for standard / default / unchecked + const defaultOutlineClass = isInverse + ? "outline outline-1 outline-[var(--color-border-inverse-primary)]" + : "outline outline-1 outline-[var(--color-border-default-tertiary)]"; + + // Apply brand outline only on actual :hover + // Standard mode uses default brand primary, inverse mode uses inverse brand primary + const conditionalHoverOutlineClass = isInverse + ? "hover:outline hover:outline-1 hover:outline-[var(--color-border-inverse-brand-primary)]" + : "hover:outline hover:outline-1 hover:outline-[var(--color-border-default-brand-primary)]"; + + // Focus state for standard/unchecked with brand primary color and specific blur/spread + const conditionalFocusClass = + "focus:outline focus:outline-1 focus:outline-[var(--color-border-default-utility-info)] focus:shadow-[0_0_10px_1px_var(--color-surface-inverse-brand-primary)]"; + + // Generate unique ID for accessibility if not provided + const generatedId = useId(); + const radioId = id || `radio-${generatedId}`; + + const handleToggle = useCallback( + (e) => { + if (!disabled && onChange && !checked) { + onChange({ checked: true, value }); + } + }, + [disabled, onChange, checked, value], + ); + + return ( +