mirror of
https://github.com/johndoe6345789/snippet-pastebin.git
synced 2026-04-24 13:34:55 +00:00
feat: implement Material Design 3 styles for buttons and dialogs
- Revamped button styles to align with Material Design 3 guidelines, including new variants (filled, tonal, elevated, outlined, text, and icon buttons). - Enhanced button states with hover, focus, and active effects, incorporating opacity transitions and background color changes. - Updated dialog styles to reflect MD3 design principles, including scrim overlay, dialog content, headers, footers, and close buttons. - Introduced animations for dialogs, dropdowns, and snackbars to improve user experience. - Refined dropdown menu and select component styles, ensuring consistency with MD3 aesthetics.
This commit is contained in:
153
CODE_STYLE.md
Normal file
153
CODE_STYLE.md
Normal file
@@ -0,0 +1,153 @@
|
||||
Here’s a practical, engineering-grade set of coding principles that actually keep a codebase healthy over years—not just during the honeymoon phase. Think of this as preventative maintenance, not moral philosophy 🛠️🧠.
|
||||
|
||||
⸻
|
||||
|
||||
1. Smallness Is a Feature
|
||||
|
||||
Large things rot faster.
|
||||
• Small files
|
||||
• Small functions
|
||||
• Small modules
|
||||
• Small responsibilities
|
||||
|
||||
If a file feels “important,” it’s probably doing too much. Decomposition beats cleverness every time.
|
||||
|
||||
Size is friction. Friction accumulates.
|
||||
|
||||
⸻
|
||||
|
||||
2. One Reason to Change
|
||||
|
||||
This is the only part of SOLID that really matters in practice.
|
||||
• Every file should exist for one reason
|
||||
• If a change request makes you touch unrelated logic, the boundary is wrong
|
||||
|
||||
Violations show up as:
|
||||
• “While I’m here…” edits
|
||||
• Fear-driven refactors
|
||||
• Accidental breakage
|
||||
|
||||
⸻
|
||||
|
||||
3. Make State Explicit (and Rare)
|
||||
|
||||
Hidden state is technical debt wearing camouflage.
|
||||
• Prefer pure functions
|
||||
• Push IO to the edges
|
||||
• Name state transitions clearly
|
||||
• Avoid “ambient” globals, singletons, magic context
|
||||
|
||||
If you can’t explain when state changes, it will betray you at scale 🧨.
|
||||
|
||||
⸻
|
||||
|
||||
4. Clarity Beats Brevity
|
||||
|
||||
Concise code is nice. Readable code survives.
|
||||
• Prefer obvious over clever
|
||||
• Use boring names that describe intent
|
||||
• Avoid dense expressions that require mental simulation
|
||||
|
||||
The best compliment for code:
|
||||
|
||||
“I didn’t have to think.”
|
||||
|
||||
⸻
|
||||
|
||||
5. Structure > Comments
|
||||
|
||||
Comments decay. Structure persists.
|
||||
• Encode intent in function names and types
|
||||
• Let data shape explain behavior
|
||||
• Use comments only for why, never what
|
||||
|
||||
If you need a paragraph to explain a function, split the function.
|
||||
|
||||
⸻
|
||||
|
||||
6. Types Are a Design Tool
|
||||
|
||||
Even in dynamic languages.
|
||||
• Types clarify contracts
|
||||
• They force edge cases into daylight
|
||||
• They prevent “just trust me” APIs
|
||||
|
||||
A good type definition is executable documentation 📐.
|
||||
|
||||
⸻
|
||||
|
||||
7. Tests Define Reality
|
||||
|
||||
Untested code is hypothetical.
|
||||
• Tests lock in behavior
|
||||
• Refactors without tests are rewrites in disguise
|
||||
• Focus on behavior, not implementation
|
||||
|
||||
Healthy ratio:
|
||||
• Few unit tests per function
|
||||
• Strong integration tests per feature
|
||||
|
||||
⸻
|
||||
|
||||
8. Errors Are First-Class
|
||||
|
||||
Failure paths deserve the same respect as success paths.
|
||||
• Handle errors explicitly
|
||||
• Avoid silent fallbacks
|
||||
• Fail fast, fail loud, fail usefully
|
||||
|
||||
Most production bugs live in “this can’t happen” branches.
|
||||
|
||||
⸻
|
||||
|
||||
9. Consistency Is More Valuable Than Correctness
|
||||
|
||||
A consistent codebase is navigable—even if imperfect.
|
||||
• Same patterns everywhere
|
||||
• Same naming conventions
|
||||
• Same folder logic
|
||||
|
||||
Inconsistency multiplies cognitive load faster than any algorithmic inefficiency.
|
||||
|
||||
⸻
|
||||
|
||||
10. Refactor Continuously, Not Heroically
|
||||
|
||||
Big refactors are usually a smell.
|
||||
• Refactor as you touch code
|
||||
• Leave things slightly better than you found them
|
||||
• Don’t let cleanup pile up into fear
|
||||
|
||||
Entropy is real. You either fight it daily or lose spectacularly 🌪️.
|
||||
|
||||
⸻
|
||||
|
||||
11. Design for Deletion
|
||||
|
||||
The best code is easy to remove.
|
||||
• Avoid tight coupling
|
||||
• Prefer composition over inheritance
|
||||
• Keep feature boundaries clean
|
||||
|
||||
If you can’t delete a feature safely, it owns you—not the other way around.
|
||||
|
||||
⸻
|
||||
|
||||
12. Tooling Is Part of the Codebase
|
||||
|
||||
Linters, formatters, CI, and automation are not optional polish.
|
||||
• Enforce rules mechanically
|
||||
• Remove human judgment where possible
|
||||
• Let tools be the bad cop
|
||||
|
||||
Humans are bad at consistency. Machines love it 🤖.
|
||||
|
||||
⸻
|
||||
|
||||
The Meta-Principle
|
||||
|
||||
A good codebase optimizes for future humans, not present cleverness.
|
||||
|
||||
Every line you write is a tiny act of communication across time. Write like Future-You is tired, busy, and slightly annoyed—but still clever enough to appreciate clean design.
|
||||
|
||||
Entropy never sleeps. Engineers must 😄
|
||||
@@ -16,86 +16,596 @@
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-neutral-3, $border);
|
||||
border-color: var(--md3-outline-variant);
|
||||
}
|
||||
|
||||
// Body and base styles
|
||||
// Body and base styles - Android Material You feel
|
||||
body {
|
||||
background: $background;
|
||||
color: $foreground;
|
||||
font-family: var(--font-inter), 'Inter', 'Bricolage Grotesque', sans-serif;
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
font-family: var(--font-inter), 'Inter', 'Roboto', system-ui, sans-serif;
|
||||
font-size: $font-body-medium;
|
||||
line-height: $line-height-body-medium;
|
||||
letter-spacing: $letter-spacing-body;
|
||||
transition: background-color $duration-medium-2 $easing-standard,
|
||||
color $duration-medium-2 $easing-standard;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
// Typography base - detailed styles in components/_typography.scss
|
||||
// ============================================
|
||||
// MD3 Typography Classes
|
||||
// ============================================
|
||||
|
||||
// Display styles
|
||||
.md3-display-large {
|
||||
font-size: $font-display-large;
|
||||
line-height: $line-height-display-large;
|
||||
letter-spacing: $letter-spacing-display;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.md3-display-medium {
|
||||
font-size: $font-display-medium;
|
||||
line-height: $line-height-display-medium;
|
||||
letter-spacing: $letter-spacing-display;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.md3-display-small {
|
||||
font-size: $font-display-small;
|
||||
line-height: $line-height-display-small;
|
||||
letter-spacing: $letter-spacing-display;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
// Headline styles
|
||||
.md3-headline-large {
|
||||
font-size: $font-headline-large;
|
||||
line-height: $line-height-headline-large;
|
||||
letter-spacing: $letter-spacing-headline;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.md3-headline-medium {
|
||||
font-size: $font-headline-medium;
|
||||
line-height: $line-height-headline-medium;
|
||||
letter-spacing: $letter-spacing-headline;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.md3-headline-small {
|
||||
font-size: $font-headline-small;
|
||||
line-height: $line-height-headline-small;
|
||||
letter-spacing: $letter-spacing-headline;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
// Title styles
|
||||
.md3-title-large {
|
||||
font-size: $font-title-large;
|
||||
line-height: $line-height-title-large;
|
||||
letter-spacing: $letter-spacing-title;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.md3-title-medium {
|
||||
font-size: $font-title-medium;
|
||||
line-height: $line-height-title-medium;
|
||||
letter-spacing: $letter-spacing-title;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.md3-title-small {
|
||||
font-size: $font-title-small;
|
||||
line-height: $line-height-title-small;
|
||||
letter-spacing: $letter-spacing-title;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Body styles
|
||||
.md3-body-large {
|
||||
font-size: $font-body-large;
|
||||
line-height: $line-height-body-large;
|
||||
letter-spacing: $letter-spacing-body;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.md3-body-medium {
|
||||
font-size: $font-body-medium;
|
||||
line-height: $line-height-body-medium;
|
||||
letter-spacing: $letter-spacing-body;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.md3-body-small {
|
||||
font-size: $font-body-small;
|
||||
line-height: $line-height-body-small;
|
||||
letter-spacing: $letter-spacing-body;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
// Label styles
|
||||
.md3-label-large {
|
||||
font-size: $font-label-large;
|
||||
line-height: $line-height-label-large;
|
||||
letter-spacing: $letter-spacing-label;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.md3-label-medium {
|
||||
font-size: $font-label-medium;
|
||||
line-height: $line-height-label-medium;
|
||||
letter-spacing: $letter-spacing-label;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.md3-label-small {
|
||||
font-size: $font-label-small;
|
||||
line-height: $line-height-label-small;
|
||||
letter-spacing: $letter-spacing-label;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// Typography base for headings
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-inter), 'Inter', 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
font-family: var(--font-inter), 'Inter', 'Roboto', system-ui, sans-serif;
|
||||
font-weight: 400;
|
||||
letter-spacing: $letter-spacing-headline;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: $font-headline-large;
|
||||
line-height: $line-height-headline-large;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: $font-headline-medium;
|
||||
line-height: $line-height-headline-medium;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: $font-headline-small;
|
||||
line-height: $line-height-headline-small;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: $font-title-large;
|
||||
line-height: $line-height-title-large;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: $font-title-medium;
|
||||
line-height: $line-height-title-medium;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: $font-title-small;
|
||||
line-height: $line-height-title-small;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
code, pre {
|
||||
font-family: var(--font-jetbrains-mono), 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-jetbrains-mono), 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||
}
|
||||
|
||||
// CSS Custom Properties for theme
|
||||
// ============================================
|
||||
// MD3 CSS Custom Properties (Dark Theme)
|
||||
// ============================================
|
||||
:root {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 263 70% 50%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 195 100% 70%;
|
||||
--accent-foreground: 222.2 84% 4.9%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 195 100% 70%;
|
||||
--radius: 0.5rem;
|
||||
// Primary
|
||||
--md3-primary: #{$primary};
|
||||
--md3-on-primary: #{$on-primary};
|
||||
--md3-primary-container: #{$primary-container};
|
||||
--md3-on-primary-container: #{$on-primary-container};
|
||||
|
||||
// Chart colors
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
// Secondary
|
||||
--md3-secondary: #{$secondary};
|
||||
--md3-on-secondary: #{$on-secondary};
|
||||
--md3-secondary-container: #{$secondary-container};
|
||||
--md3-on-secondary-container: #{$on-secondary-container};
|
||||
|
||||
// Sidebar colors
|
||||
--sidebar: 217.2 32.6% 17.5%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 263 70% 50%;
|
||||
--sidebar-primary-foreground: 210 40% 98%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 195 100% 70%;
|
||||
// Tertiary
|
||||
--md3-tertiary: #{$accent};
|
||||
--md3-on-tertiary: #{$on-tertiary};
|
||||
--md3-tertiary-container: #{$tertiary-container};
|
||||
--md3-on-tertiary-container: #{$on-tertiary-container};
|
||||
|
||||
// Error
|
||||
--md3-error: #{$destructive};
|
||||
--md3-on-error: #{$on-error};
|
||||
--md3-error-container: #{$error-container};
|
||||
--md3-on-error-container: #{$on-error-container};
|
||||
|
||||
// Surface
|
||||
--md3-surface: #{$surface};
|
||||
--md3-on-surface: #{$on-surface};
|
||||
--md3-surface-variant: #{$surface-variant};
|
||||
--md3-on-surface-variant: #{$on-surface-variant};
|
||||
--md3-surface-dim: #{$surface-dim};
|
||||
--md3-surface-bright: #{$surface-bright};
|
||||
|
||||
// Surface Containers (MD3 tonal elevation)
|
||||
--md3-surface-container-lowest: #{$surface-container-lowest};
|
||||
--md3-surface-container-low: #{$surface-container-low};
|
||||
--md3-surface-container: #{$surface-container};
|
||||
--md3-surface-container-high: #{$surface-container-high};
|
||||
--md3-surface-container-highest: #{$surface-container-highest};
|
||||
|
||||
// Background
|
||||
--md3-background: #{$background};
|
||||
--md3-on-background: #{$on-background};
|
||||
|
||||
// Outline
|
||||
--md3-outline: #{$outline};
|
||||
--md3-outline-variant: #{$outline-variant};
|
||||
|
||||
// Inverse
|
||||
--md3-inverse-surface: #{$inverse-surface};
|
||||
--md3-inverse-on-surface: #{$inverse-on-surface};
|
||||
--md3-inverse-primary: #{$inverse-primary};
|
||||
|
||||
// Scrim & Shadow
|
||||
--md3-scrim: #{$scrim};
|
||||
--md3-shadow: #{$shadow};
|
||||
|
||||
// Semantic colors
|
||||
--md3-success: #{$md3-success-80};
|
||||
--md3-on-success: #{$md3-success-40};
|
||||
--md3-warning: #{$md3-warning-80};
|
||||
--md3-on-warning: #{$md3-warning-40};
|
||||
--md3-info: #{$md3-info-80};
|
||||
--md3-on-info: #{$md3-info-40};
|
||||
|
||||
// State layers
|
||||
--md3-state-hover: #{$state-hover-opacity};
|
||||
--md3-state-focus: #{$state-focus-opacity};
|
||||
--md3-state-pressed: #{$state-pressed-opacity};
|
||||
--md3-state-dragged: #{$state-dragged-opacity};
|
||||
--md3-state-disabled: #{$state-disabled-opacity};
|
||||
|
||||
// Elevation
|
||||
--md3-elevation-1: #{$elevation-1};
|
||||
--md3-elevation-2: #{$elevation-2};
|
||||
--md3-elevation-3: #{$elevation-3};
|
||||
--md3-elevation-4: #{$elevation-4};
|
||||
--md3-elevation-5: #{$elevation-5};
|
||||
|
||||
// Motion
|
||||
--md3-duration-short: #{$duration-short-4};
|
||||
--md3-duration-medium: #{$duration-medium-2};
|
||||
--md3-duration-long: #{$duration-long-2};
|
||||
--md3-easing-standard: #{$easing-standard};
|
||||
--md3-easing-emphasized: #{$easing-emphasized};
|
||||
--md3-easing-emphasized-decelerate: #{$easing-emphasized-decelerate};
|
||||
|
||||
// Shape
|
||||
--md3-shape-extra-small: #{$radius-extra-small};
|
||||
--md3-shape-small: #{$radius-small};
|
||||
--md3-shape-medium: #{$radius-medium};
|
||||
--md3-shape-large: #{$radius-large};
|
||||
--md3-shape-extra-large: #{$radius-extra-large};
|
||||
--md3-shape-full: #{$radius-full};
|
||||
|
||||
// Legacy compatibility
|
||||
--background: 260 10% 6%;
|
||||
--foreground: 260 10% 90%;
|
||||
--card: 260 10% 10%;
|
||||
--card-foreground: 260 10% 90%;
|
||||
--popover: 260 10% 17%;
|
||||
--popover-foreground: 260 10% 90%;
|
||||
--primary: 260 50% 80%;
|
||||
--primary-foreground: 260 50% 20%;
|
||||
--secondary: 260 15% 80%;
|
||||
--secondary-foreground: 260 15% 20%;
|
||||
--muted: 260 10% 12%;
|
||||
--muted-foreground: 260 15% 80%;
|
||||
--accent: 180 50% 80%;
|
||||
--accent-foreground: 180 50% 20%;
|
||||
--destructive: 0 75% 80%;
|
||||
--destructive-foreground: 0 75% 20%;
|
||||
--border: 260 15% 30%;
|
||||
--input: 260 10% 22%;
|
||||
--ring: 260 50% 80%;
|
||||
--radius: 0.75rem;
|
||||
|
||||
// Chart colors (MD3 inspired)
|
||||
--chart-1: #{$md3-primary-60};
|
||||
--chart-2: #{$md3-secondary-60};
|
||||
--chart-3: #{$md3-tertiary-60};
|
||||
--chart-4: #{$md3-error-60};
|
||||
--chart-5: #{$md3-warning-80};
|
||||
|
||||
// Sidebar colors (MD3)
|
||||
--sidebar: 260 10% 10%;
|
||||
--sidebar-foreground: 260 10% 90%;
|
||||
--sidebar-primary: 260 50% 80%;
|
||||
--sidebar-primary-foreground: 260 50% 20%;
|
||||
--sidebar-accent: 260 10% 17%;
|
||||
--sidebar-accent-foreground: 260 10% 90%;
|
||||
--sidebar-border: 260 15% 30%;
|
||||
--sidebar-ring: 260 50% 80%;
|
||||
}
|
||||
|
||||
// Accordion animations
|
||||
// ============================================
|
||||
// MD3 Surface Utility Classes
|
||||
// ============================================
|
||||
|
||||
.md3-surface {
|
||||
background-color: var(--md3-surface);
|
||||
color: var(--md3-on-surface);
|
||||
}
|
||||
|
||||
.md3-surface-dim {
|
||||
background-color: var(--md3-surface-dim);
|
||||
color: var(--md3-on-surface);
|
||||
}
|
||||
|
||||
.md3-surface-bright {
|
||||
background-color: var(--md3-surface-bright);
|
||||
color: var(--md3-on-surface);
|
||||
}
|
||||
|
||||
.md3-surface-container-lowest {
|
||||
background-color: var(--md3-surface-container-lowest);
|
||||
color: var(--md3-on-surface);
|
||||
}
|
||||
|
||||
.md3-surface-container-low {
|
||||
background-color: var(--md3-surface-container-low);
|
||||
color: var(--md3-on-surface);
|
||||
}
|
||||
|
||||
.md3-surface-container {
|
||||
background-color: var(--md3-surface-container);
|
||||
color: var(--md3-on-surface);
|
||||
}
|
||||
|
||||
.md3-surface-container-high {
|
||||
background-color: var(--md3-surface-container-high);
|
||||
color: var(--md3-on-surface);
|
||||
}
|
||||
|
||||
.md3-surface-container-highest {
|
||||
background-color: var(--md3-surface-container-highest);
|
||||
color: var(--md3-on-surface);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MD3 Elevation Classes
|
||||
// ============================================
|
||||
|
||||
.md3-elevation-1 {
|
||||
box-shadow: var(--md3-elevation-1);
|
||||
}
|
||||
|
||||
.md3-elevation-2 {
|
||||
box-shadow: var(--md3-elevation-2);
|
||||
}
|
||||
|
||||
.md3-elevation-3 {
|
||||
box-shadow: var(--md3-elevation-3);
|
||||
}
|
||||
|
||||
.md3-elevation-4 {
|
||||
box-shadow: var(--md3-elevation-4);
|
||||
}
|
||||
|
||||
.md3-elevation-5 {
|
||||
box-shadow: var(--md3-elevation-5);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MD3 State Layer Mixin Classes
|
||||
// ============================================
|
||||
|
||||
.md3-state-layer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity $duration-short-4 $easing-standard;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: var(--md3-state-hover);
|
||||
}
|
||||
|
||||
&:focus-visible::before {
|
||||
opacity: var(--md3-state-focus);
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
opacity: var(--md3-state-pressed);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MD3 Ripple Effect
|
||||
// ============================================
|
||||
|
||||
@keyframes md3-ripple {
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 0.12;
|
||||
}
|
||||
to {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.md3-ripple {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
background: radial-gradient(circle, currentColor 10%, transparent 10.01%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&:active::after {
|
||||
animation: md3-ripple $duration-medium-4 $easing-standard;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Animations (MD3 Motion)
|
||||
// ============================================
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes md3-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes md3-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes md3-scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes md3-slide-in-bottom {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes md3-slide-in-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Android-style System UI
|
||||
// ============================================
|
||||
|
||||
// Status bar simulation (optional)
|
||||
.android-status-bar {
|
||||
height: 24px;
|
||||
background: var(--md3-surface-container-lowest);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 $size-4;
|
||||
font-size: $font-label-small;
|
||||
color: var(--md3-on-surface);
|
||||
}
|
||||
|
||||
// Navigation bar simulation (bottom gesture bar)
|
||||
.android-nav-bar {
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: $size-2;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 134px;
|
||||
height: 5px;
|
||||
background: var(--md3-on-surface-variant);
|
||||
border-radius: $radius-full;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
// Selection styling
|
||||
::selection {
|
||||
background: rgba($primary, 0.3);
|
||||
color: $on-surface;
|
||||
}
|
||||
|
||||
// Scrollbar styling (Android-like thin scrollbar)
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--md3-outline-variant);
|
||||
border-radius: $radius-full;
|
||||
|
||||
&:hover {
|
||||
background: var(--md3-outline);
|
||||
}
|
||||
}
|
||||
|
||||
// Focus visible styling (MD3 style)
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--md3-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Play, CircleNotch, Terminal } from '@phosphor-icons/react'
|
||||
import { Play, CircleNotch, ArrowClockwise, Warning } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { runPythonCode, getPyodide, isPyodideReady } from '@/lib/pyodide-runner'
|
||||
import { runPythonCode, getPyodide, isPyodideReady, getPyodideError, resetPyodide } from '@/lib/pyodide-runner'
|
||||
import { PythonTerminal } from '@/components/features/python-runner/PythonTerminal'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -17,30 +16,71 @@ export function PythonOutput({ code }: PythonOutputProps) {
|
||||
const [error, setError] = useState<string>('')
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [isInitializing, setIsInitializing] = useState(!isPyodideReady())
|
||||
const [initError, setInitError] = useState<string | null>(null)
|
||||
const [hasInput, setHasInput] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPyodideReady()) {
|
||||
setIsInitializing(true)
|
||||
getPyodide()
|
||||
.then(() => {
|
||||
setIsInitializing(false)
|
||||
toast.success('Python environment ready!')
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsInitializing(false)
|
||||
toast.error('Failed to load Python environment')
|
||||
console.error(err)
|
||||
})
|
||||
const initializePyodide = useCallback(async () => {
|
||||
setIsInitializing(true)
|
||||
setInitError(null)
|
||||
|
||||
try {
|
||||
await getPyodide()
|
||||
setIsInitializing(false)
|
||||
toast.success('Python environment ready!')
|
||||
} catch (err) {
|
||||
setIsInitializing(false)
|
||||
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||
setInitError(errorMsg)
|
||||
toast.error('Failed to load Python environment')
|
||||
console.error(err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
resetPyodide()
|
||||
setOutput('')
|
||||
setError('')
|
||||
setInitError(null)
|
||||
initializePyodide()
|
||||
}, [initializePyodide])
|
||||
|
||||
useEffect(() => {
|
||||
// Check for existing initialization error
|
||||
const existingError = getPyodideError()
|
||||
if (existingError) {
|
||||
setInitError(existingError.message)
|
||||
setIsInitializing(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPyodideReady()) {
|
||||
initializePyodide()
|
||||
}
|
||||
}, [initializePyodide])
|
||||
|
||||
useEffect(() => {
|
||||
const codeToCheck = code.toLowerCase()
|
||||
setHasInput(codeToCheck.includes('input('))
|
||||
}, [code])
|
||||
|
||||
const statusTone = initError
|
||||
? 'border-destructive/30 bg-destructive/10 text-destructive'
|
||||
: isInitializing
|
||||
? 'border-border bg-primary/5 text-primary'
|
||||
: 'border-primary/30 bg-primary/10 text-primary'
|
||||
|
||||
const statusLabel = initError
|
||||
? 'Init failed'
|
||||
: isInitializing
|
||||
? 'Loading'
|
||||
: 'Ready'
|
||||
|
||||
const handleRun = async () => {
|
||||
if (initError) {
|
||||
toast.error('Python environment failed to start. Retry to load it again.')
|
||||
return
|
||||
}
|
||||
|
||||
if (isInitializing) {
|
||||
toast.info('Python environment is still loading...')
|
||||
return
|
||||
@@ -69,36 +109,112 @@ export function PythonOutput({ code }: PythonOutputProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-card">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground">Python Output</h3>
|
||||
<Button
|
||||
onClick={handleRun}
|
||||
disabled={isRunning || isInitializing}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
{isRunning || isInitializing ? (
|
||||
<>
|
||||
<CircleNotch className="animate-spin" size={16} />
|
||||
{isInitializing ? 'Loading...' : 'Running...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} weight="fill" />
|
||||
Run
|
||||
</>
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">Python Output</h3>
|
||||
<span
|
||||
className={`flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-wide ${statusTone}`}
|
||||
>
|
||||
<span
|
||||
className={`size-2.5 rounded-full ${initError ? 'bg-destructive' : 'bg-primary'}`}
|
||||
/>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{initError && (
|
||||
<Button
|
||||
onClick={handleRetry}
|
||||
size="sm"
|
||||
variant="tonal"
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowClockwise size={16} />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRun}
|
||||
disabled={isRunning || isInitializing || Boolean(initError)}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
{isRunning || isInitializing ? (
|
||||
<>
|
||||
<CircleNotch className="animate-spin" size={16} />
|
||||
{isInitializing ? 'Loading...' : 'Running...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} weight="fill" />
|
||||
Run
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{!output && !error && (
|
||||
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||
{isInitializing && (
|
||||
<Card variant="filled" className="p-4 border border-border/60">
|
||||
<div className="flex items-start gap-3 text-sm text-foreground">
|
||||
<div className="mt-1 rounded-full bg-primary/10 p-2 text-primary">
|
||||
<CircleNotch className="animate-spin" size={18} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">Preparing Python environment</p>
|
||||
<p className="text-muted-foreground">
|
||||
This takes a few seconds the first time while Pyodide downloads.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{initError && (
|
||||
<Card
|
||||
variant="outlined"
|
||||
className="p-4 border-destructive/40 bg-destructive/5"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1 rounded-full bg-destructive/10 p-2 text-destructive">
|
||||
<Warning size={18} weight="fill" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-destructive">Python environment failed to start</p>
|
||||
<p className="text-sm text-muted-foreground">{initError}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tonal"
|
||||
className="gap-2"
|
||||
onClick={handleRetry}
|
||||
>
|
||||
<ArrowClockwise size={16} />
|
||||
Retry loading
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onClick={() => toast.info('Check your network connection or disable blockers that may block Pyodide.')}
|
||||
>
|
||||
Troubleshoot
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isInitializing && !initError && !output && !error && (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Click "Run" to execute the Python code
|
||||
</div>
|
||||
)}
|
||||
|
||||
{output && (
|
||||
{!isInitializing && !initError && output && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
||||
@@ -1,67 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { X } from '@phosphor-icons/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { navigationItems } from './navigation-items';
|
||||
import { useNavigation } from './useNavigation';
|
||||
|
||||
/**
|
||||
* Material Design 3 Navigation Drawer
|
||||
*
|
||||
* Android-style modal navigation drawer with:
|
||||
* - Scrim overlay
|
||||
* - Slide-in animation
|
||||
* - Active indicator pill
|
||||
* - MD3 typography and spacing
|
||||
*/
|
||||
export function NavigationSidebar() {
|
||||
const { menuOpen, setMenuOpen } = useNavigation();
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
initial={false}
|
||||
animate={{ x: menuOpen ? 0 : -320 }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed left-0 top-0 h-screen w-80 bg-card border-r border-border z-30 flex flex-col"
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 border-b border-border flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Navigation</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<>
|
||||
{/* Scrim/Overlay */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-40 bg-black/32"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<nav className="p-4">
|
||||
<ul className="space-y-2">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.path;
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<Link href={item.path}>
|
||||
<Button
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
className={cn(
|
||||
'w-full justify-start gap-3',
|
||||
isActive && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{item.label}
|
||||
</Button>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="p-6 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
CodeSnippet Library
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.aside>
|
||||
{/* Navigation Drawer */}
|
||||
<motion.aside
|
||||
initial={{ x: -320 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -320 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
damping: 30,
|
||||
stiffness: 300,
|
||||
mass: 0.8
|
||||
}}
|
||||
className={cn(
|
||||
"fixed left-0 top-0 h-screen w-80 z-50",
|
||||
"bg-[hsl(var(--card))]",
|
||||
"flex flex-col",
|
||||
"shadow-xl"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-border/50">
|
||||
<span className="text-base font-medium text-foreground pl-3">
|
||||
CodeSnippet
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
"size-10 rounded-full",
|
||||
"text-muted-foreground",
|
||||
"hover:bg-foreground/[0.08]",
|
||||
"active:bg-foreground/[0.12]",
|
||||
"transition-colors duration-200",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
)}
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<X weight="bold" className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation Items */}
|
||||
<nav className="flex-1 overflow-y-auto py-2 px-3">
|
||||
<ul className="space-y-0.5">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.path;
|
||||
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<Link
|
||||
href={item.path}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={cn(
|
||||
"relative flex items-center gap-3",
|
||||
"h-14 px-4 rounded-full",
|
||||
"text-sm font-medium",
|
||||
"transition-all duration-200",
|
||||
"outline-none",
|
||||
// State layer
|
||||
"before:absolute before:inset-0 before:rounded-full",
|
||||
"before:bg-current before:opacity-0 before:transition-opacity",
|
||||
"hover:before:opacity-[0.08]",
|
||||
"focus-visible:before:opacity-[0.12]",
|
||||
"active:before:opacity-[0.12]",
|
||||
// Active state
|
||||
isActive
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
weight={isActive ? "fill" : "regular"}
|
||||
className="size-6 relative z-10 shrink-0"
|
||||
/>
|
||||
<span className="relative z-10">{item.label}</span>
|
||||
|
||||
{/* Active indicator line */}
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="nav-indicator"
|
||||
className="absolute right-3 w-1 h-4 bg-primary rounded-full"
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 500 }}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-border/50">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Material Design 3
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Android gesture bar indicator */}
|
||||
<div className="h-5 flex items-center justify-center pb-2">
|
||||
<div className="w-[134px] h-[5px] bg-muted-foreground/40 rounded-full" />
|
||||
</div>
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,23 +4,80 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Material Design 3 Badge Component
|
||||
*
|
||||
* Used for:
|
||||
* - Status indicators
|
||||
* - Labels
|
||||
* - Small counts (notification badges use a different pattern)
|
||||
*/
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
|
||||
// Base styles
|
||||
[
|
||||
"inline-flex items-center justify-center gap-1",
|
||||
"rounded-full px-2.5 py-0.5",
|
||||
"text-xs font-medium",
|
||||
"whitespace-nowrap shrink-0 w-fit",
|
||||
"transition-colors duration-200",
|
||||
"[&>svg]:size-3 [&>svg]:pointer-events-none",
|
||||
"outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
|
||||
].join(" "),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
// Filled badge (primary)
|
||||
default: [
|
||||
"border-transparent bg-primary text-primary-foreground",
|
||||
"[a&]:hover:bg-primary/90",
|
||||
].join(" "),
|
||||
|
||||
// Tonal badge (secondary container)
|
||||
secondary: [
|
||||
"border-transparent bg-secondary text-secondary-foreground",
|
||||
"[a&]:hover:bg-secondary/90",
|
||||
].join(" "),
|
||||
|
||||
// Error badge
|
||||
destructive: [
|
||||
"border-transparent bg-destructive text-destructive-foreground",
|
||||
"[a&]:hover:bg-destructive/90",
|
||||
].join(" "),
|
||||
|
||||
// Outlined badge
|
||||
outline: [
|
||||
"border border-border bg-transparent text-foreground",
|
||||
"[a&]:hover:bg-muted",
|
||||
].join(" "),
|
||||
|
||||
// Success badge
|
||||
success: [
|
||||
"border-transparent bg-[hsl(145,60%,35%)] text-white",
|
||||
"[a&]:hover:opacity-90",
|
||||
].join(" "),
|
||||
|
||||
// Warning badge
|
||||
warning: [
|
||||
"border-transparent bg-[hsl(40,80%,45%)] text-white",
|
||||
"[a&]:hover:opacity-90",
|
||||
].join(" "),
|
||||
|
||||
// Info badge
|
||||
info: [
|
||||
"border-transparent bg-[hsl(210,70%,45%)] text-white",
|
||||
"[a&]:hover:opacity-90",
|
||||
].join(" "),
|
||||
},
|
||||
size: {
|
||||
default: "h-5 px-2.5 text-xs",
|
||||
sm: "h-4 px-2 text-[10px]",
|
||||
lg: "h-6 px-3 text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -28,6 +85,7 @@ const badgeVariants = cva(
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: ComponentProps<"span"> &
|
||||
@@ -37,10 +95,45 @@ function Badge({
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
className={cn(badgeVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
/**
|
||||
* MD3 Small Badge (for notification counts)
|
||||
* Can be a dot or show a number
|
||||
*/
|
||||
function SmallBadge({
|
||||
className,
|
||||
count,
|
||||
max = 99,
|
||||
...props
|
||||
}: ComponentProps<"span"> & {
|
||||
count?: number
|
||||
max?: number
|
||||
}) {
|
||||
const showDot = count === undefined || count === 0
|
||||
const displayCount = count && count > max ? `${max}+` : count
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="small-badge"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
"bg-destructive text-destructive-foreground",
|
||||
"font-medium",
|
||||
showDot
|
||||
? "size-2 rounded-full"
|
||||
: "min-w-[16px] h-4 px-1 rounded-full text-[10px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!showDot && displayCount}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, SmallBadge, badgeVariants }
|
||||
|
||||
149
src/components/ui/bottom-navigation.tsx
Normal file
149
src/components/ui/bottom-navigation.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { ComponentProps, createContext, forwardRef, useContext } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Material Design 3 Bottom Navigation Bar
|
||||
*
|
||||
* Android-style bottom navigation with:
|
||||
* - 3-5 destinations
|
||||
* - Active indicator pill
|
||||
* - Icon + label layout
|
||||
* - Badge support
|
||||
*/
|
||||
|
||||
interface BottomNavigationContextValue {
|
||||
activeValue?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
const BottomNavigationContext = createContext<BottomNavigationContextValue>({})
|
||||
|
||||
interface BottomNavigationProps extends ComponentProps<"nav"> {
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
const BottomNavigation = forwardRef<HTMLElement, BottomNavigationProps>(
|
||||
({ className, value, onValueChange, children, ...props }, ref) => {
|
||||
return (
|
||||
<BottomNavigationContext.Provider value={{ activeValue: value, onValueChange }}>
|
||||
<nav
|
||||
ref={ref}
|
||||
data-slot="bottom-navigation"
|
||||
className={cn(
|
||||
// Container styles
|
||||
"fixed bottom-0 left-0 right-0 z-50",
|
||||
"h-20 px-2",
|
||||
"bg-[hsl(var(--card))]",
|
||||
"border-t border-border/50",
|
||||
"shadow-[0_-2px_10px_rgba(0,0,0,0.1)]",
|
||||
// Flex layout
|
||||
"flex items-center justify-around",
|
||||
// Safe area for notched phones
|
||||
"pb-safe",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
</BottomNavigationContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
BottomNavigation.displayName = "BottomNavigation"
|
||||
|
||||
interface BottomNavigationItemProps extends ComponentProps<"button"> {
|
||||
value: string
|
||||
icon: React.ReactNode
|
||||
activeIcon?: React.ReactNode
|
||||
label: string
|
||||
badge?: number | boolean
|
||||
}
|
||||
|
||||
const BottomNavigationItem = forwardRef<HTMLButtonElement, BottomNavigationItemProps>(
|
||||
({ className, value, icon, activeIcon, label, badge, ...props }, ref) => {
|
||||
const context = useContext(BottomNavigationContext)
|
||||
const isActive = context.activeValue === value
|
||||
|
||||
const handleClick = () => {
|
||||
context.onValueChange?.(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-slot="bottom-navigation-item"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
// Container
|
||||
"relative flex flex-col items-center justify-center",
|
||||
"min-w-[64px] h-full px-3 py-2",
|
||||
"bg-transparent border-0 cursor-pointer",
|
||||
"transition-all duration-200",
|
||||
"outline-none",
|
||||
// Focus state
|
||||
"focus-visible:outline-none",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{/* Active indicator pill */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-2 w-16 h-8 rounded-full",
|
||||
"bg-primary/20",
|
||||
"transform transition-all duration-200",
|
||||
isActive ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Icon container */}
|
||||
<span
|
||||
className={cn(
|
||||
"relative z-10 flex items-center justify-center",
|
||||
"w-6 h-6 mb-1",
|
||||
"[&_svg]:size-6",
|
||||
"transition-colors duration-200",
|
||||
isActive ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isActive && activeIcon ? activeIcon : icon}
|
||||
|
||||
{/* Badge */}
|
||||
{badge !== undefined && badge !== false && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-1 -right-2",
|
||||
"flex items-center justify-center",
|
||||
"min-w-[16px] h-4 px-1",
|
||||
"bg-destructive text-destructive-foreground",
|
||||
"text-[10px] font-medium",
|
||||
"rounded-full"
|
||||
)}
|
||||
>
|
||||
{typeof badge === "number" ? (badge > 99 ? "99+" : badge) : ""}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Label */}
|
||||
<span
|
||||
className={cn(
|
||||
"relative z-10 text-xs font-medium",
|
||||
"transition-colors duration-200",
|
||||
isActive ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
BottomNavigationItem.displayName = "BottomNavigationItem"
|
||||
|
||||
export { BottomNavigation, BottomNavigationItem }
|
||||
@@ -4,28 +4,111 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Material Design 3 Button Component
|
||||
*
|
||||
* Variants:
|
||||
* - filled (default): High emphasis, primary actions (MD3 Filled Button)
|
||||
* - tonal: Medium emphasis, secondary actions (MD3 Filled Tonal Button)
|
||||
* - elevated: Medium emphasis with shadow (MD3 Elevated Button)
|
||||
* - outlined: Low emphasis, secondary actions (MD3 Outlined Button)
|
||||
* - text: Lowest emphasis, tertiary actions (MD3 Text Button)
|
||||
* - destructive: Error/danger actions
|
||||
* - ghost: Legacy support
|
||||
* - link: Text link style
|
||||
*/
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all 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",
|
||||
// Base styles - MD3 common button properties
|
||||
[
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap",
|
||||
"rounded-full text-sm font-medium",
|
||||
"transition-all duration-200",
|
||||
"disabled:pointer-events-none disabled:opacity-[0.38]",
|
||||
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-[18px]",
|
||||
"shrink-0 [&_svg]:shrink-0",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"relative overflow-hidden select-none",
|
||||
// State layer using pseudo-element
|
||||
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-current before:opacity-0 before:transition-opacity before:duration-200",
|
||||
"hover:before:opacity-[0.08] focus-visible:before:opacity-[0.12] active:before:opacity-[0.12]",
|
||||
].join(" "),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive 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 hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
// MD3 Filled Button - Primary, highest emphasis
|
||||
default: [
|
||||
"bg-primary text-primary-foreground",
|
||||
"shadow-sm",
|
||||
"hover:shadow-md",
|
||||
"active:shadow-sm",
|
||||
].join(" "),
|
||||
|
||||
// MD3 Filled Tonal Button - Secondary, medium emphasis
|
||||
tonal: [
|
||||
"bg-secondary text-secondary-foreground",
|
||||
"hover:shadow-sm",
|
||||
].join(" "),
|
||||
|
||||
// MD3 Elevated Button - Medium emphasis with elevation
|
||||
elevated: [
|
||||
"bg-card text-primary",
|
||||
"shadow-md",
|
||||
"hover:shadow-lg",
|
||||
"active:shadow-md",
|
||||
].join(" "),
|
||||
|
||||
// MD3 Outlined Button - Low emphasis
|
||||
outline: [
|
||||
"border border-border bg-transparent text-primary",
|
||||
"hover:bg-primary/[0.08]",
|
||||
].join(" "),
|
||||
|
||||
// MD3 Text Button - Lowest emphasis
|
||||
text: [
|
||||
"bg-transparent text-primary",
|
||||
"hover:bg-primary/[0.08]",
|
||||
"px-3",
|
||||
].join(" "),
|
||||
|
||||
// Destructive/Error variant
|
||||
destructive: [
|
||||
"bg-destructive text-destructive-foreground",
|
||||
"shadow-sm",
|
||||
"hover:shadow-md",
|
||||
"focus-visible:ring-destructive",
|
||||
].join(" "),
|
||||
|
||||
// Legacy secondary (maps to tonal)
|
||||
secondary: [
|
||||
"bg-secondary text-secondary-foreground",
|
||||
"hover:shadow-sm",
|
||||
].join(" "),
|
||||
|
||||
// Ghost - for icon buttons and minimal UI
|
||||
ghost: [
|
||||
"bg-transparent text-muted-foreground",
|
||||
"hover:bg-muted hover:text-foreground",
|
||||
].join(" "),
|
||||
|
||||
// Link style
|
||||
link: [
|
||||
"text-primary underline-offset-4 hover:underline",
|
||||
"bg-transparent",
|
||||
].join(" "),
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
// MD3 standard button height is 40dp
|
||||
default: "h-10 px-6 py-2.5 min-w-[64px] has-[>svg]:px-4",
|
||||
// Compact size
|
||||
sm: "h-8 px-4 gap-1.5 text-xs has-[>svg]:px-3",
|
||||
// Large for prominent actions
|
||||
lg: "h-12 px-8 text-base has-[>svg]:px-6",
|
||||
// Icon button (MD3 standard icon button is 40x40)
|
||||
icon: "size-10 p-0 min-w-0 rounded-full",
|
||||
// Small icon button
|
||||
"icon-sm": "size-8 p-0 min-w-0 rounded-full",
|
||||
// Large icon button
|
||||
"icon-lg": "size-12 p-0 min-w-0 rounded-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -1,15 +1,58 @@
|
||||
import { ComponentProps } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: ComponentProps<"div">) {
|
||||
/**
|
||||
* Material Design 3 Card Component
|
||||
*
|
||||
* Variants:
|
||||
* - elevated: Default card with shadow (MD3 Elevated Card)
|
||||
* - filled: Card with tonal surface color (MD3 Filled Card)
|
||||
* - outlined: Card with outline border (MD3 Outlined Card)
|
||||
*/
|
||||
const cardVariants = cva(
|
||||
// Base styles
|
||||
[
|
||||
"flex flex-col gap-4 rounded-xl text-card-foreground",
|
||||
"transition-all duration-200",
|
||||
"relative overflow-hidden",
|
||||
].join(" "),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// MD3 Elevated Card - default surface with shadow
|
||||
elevated: [
|
||||
"bg-card shadow-md",
|
||||
"hover:shadow-lg",
|
||||
].join(" "),
|
||||
|
||||
// MD3 Filled Card - surface container color, no shadow
|
||||
filled: [
|
||||
"bg-secondary/50",
|
||||
].join(" "),
|
||||
|
||||
// MD3 Outlined Card - transparent with border
|
||||
outlined: [
|
||||
"bg-card border border-border",
|
||||
].join(" "),
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "elevated",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Card({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: ComponentProps<"div"> & VariantProps<typeof cardVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
className={cn(cardVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -20,7 +63,7 @@ function CardHeader({ className, ...props }: ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1 px-4 pt-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -32,7 +75,10 @@ function CardTitle({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
className={cn(
|
||||
"text-base font-medium leading-tight tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -42,7 +88,7 @@ function CardDescription({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -65,7 +111,7 @@ function CardContent({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
className={cn("px-4 pb-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -75,7 +121,30 @@ function CardFooter({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 pb-4 pt-2 [.border-t]:pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* MD3 Card Media - for images/videos at top of card
|
||||
*/
|
||||
function CardMedia({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-media"
|
||||
className={cn(
|
||||
"relative -mx-0 -mt-0 overflow-hidden rounded-t-xl",
|
||||
"aspect-video bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -89,4 +158,6 @@ export {
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
cardVariants,
|
||||
}
|
||||
|
||||
166
src/components/ui/chip.tsx
Normal file
166
src/components/ui/chip.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import { ComponentProps, forwardRef } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "@phosphor-icons/react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Material Design 3 Chip Component
|
||||
*
|
||||
* Types:
|
||||
* - assist: Actions in context (Assist Chip)
|
||||
* - filter: Toggleable filters (Filter Chip)
|
||||
* - input: User input, removable (Input Chip)
|
||||
* - suggestion: Dynamic suggestions (Suggestion Chip)
|
||||
*/
|
||||
const chipVariants = cva(
|
||||
// Base styles
|
||||
[
|
||||
"inline-flex items-center justify-center gap-2",
|
||||
"h-8 px-4 rounded-lg",
|
||||
"text-sm font-medium",
|
||||
"transition-all duration-200",
|
||||
"cursor-pointer select-none",
|
||||
"outline-none",
|
||||
"relative overflow-hidden",
|
||||
// State layer
|
||||
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-current before:opacity-0 before:transition-opacity before:duration-200",
|
||||
"hover:before:opacity-[0.08] focus-visible:before:opacity-[0.12] active:before:opacity-[0.12]",
|
||||
// Focus ring
|
||||
"focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
// Disabled
|
||||
"disabled:pointer-events-none disabled:opacity-[0.38]",
|
||||
// Icon sizing
|
||||
"[&_svg]:size-[18px] [&_svg]:shrink-0",
|
||||
].join(" "),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// Assist Chip - outlined, for contextual actions
|
||||
assist: [
|
||||
"border border-border bg-transparent text-foreground",
|
||||
"hover:bg-muted/50",
|
||||
].join(" "),
|
||||
|
||||
// Filter Chip - toggleable, outlined or filled when selected
|
||||
filter: [
|
||||
"border border-border bg-transparent text-foreground",
|
||||
"data-[selected=true]:bg-secondary data-[selected=true]:border-transparent data-[selected=true]:text-secondary-foreground",
|
||||
].join(" "),
|
||||
|
||||
// Input Chip - user input, removable
|
||||
input: [
|
||||
"border border-border bg-transparent text-foreground",
|
||||
"pr-2",
|
||||
].join(" "),
|
||||
|
||||
// Suggestion Chip - outlined, for suggestions
|
||||
suggestion: [
|
||||
"border border-border bg-transparent text-foreground",
|
||||
"hover:bg-muted/50",
|
||||
].join(" "),
|
||||
|
||||
// Filled variant
|
||||
filled: [
|
||||
"bg-secondary text-secondary-foreground border-transparent",
|
||||
].join(" "),
|
||||
},
|
||||
size: {
|
||||
default: "h-8 px-4 text-sm",
|
||||
sm: "h-6 px-3 text-xs",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "assist",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface ChipProps
|
||||
extends ComponentProps<"button">,
|
||||
VariantProps<typeof chipVariants> {
|
||||
leadingIcon?: React.ReactNode
|
||||
selected?: boolean
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
const Chip = forwardRef<HTMLButtonElement, ChipProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
leadingIcon,
|
||||
selected,
|
||||
onRemove,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isInput = variant === "input"
|
||||
const isFilter = variant === "filter"
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-slot="chip"
|
||||
data-selected={isFilter ? selected : undefined}
|
||||
className={cn(chipVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
>
|
||||
{isFilter && selected && (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{leadingIcon && !isFilter && leadingIcon}
|
||||
<span className="relative z-10">{children}</span>
|
||||
{isInput && onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}}
|
||||
className={cn(
|
||||
"relative z-10 flex items-center justify-center",
|
||||
"size-[18px] rounded-full",
|
||||
"hover:bg-foreground/10",
|
||||
"transition-colors duration-200"
|
||||
)}
|
||||
>
|
||||
<X weight="bold" className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
Chip.displayName = "Chip"
|
||||
|
||||
/**
|
||||
* Chip Group container
|
||||
*/
|
||||
function ChipGroup({ className, ...props }: ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="chip-group"
|
||||
className={cn("flex flex-wrap gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Chip, ChipGroup, chipVariants }
|
||||
@@ -8,6 +8,15 @@ import CircleIcon from "lucide-react/dist/esm/icons/circle"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Material Design 3 Dropdown Menu
|
||||
*
|
||||
* Android-style menu with:
|
||||
* - 48dp minimum touch targets
|
||||
* - Proper state layers
|
||||
* - Clear focus indicators
|
||||
*/
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
@@ -35,7 +44,7 @@ function DropdownMenuTrigger({
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
@@ -44,7 +53,22 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
// MD3 surface container
|
||||
"bg-[hsl(var(--popover))] text-[hsl(var(--popover-foreground))]",
|
||||
// Shape and shadow
|
||||
"z-50 min-w-[180px] overflow-hidden rounded-lg border-0 p-1",
|
||||
"shadow-lg",
|
||||
// Constrain height and enable scrolling
|
||||
"max-h-[min(var(--radix-dropdown-menu-content-available-height),400px)]",
|
||||
"overflow-y-auto",
|
||||
// Animations
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
// Transform origin
|
||||
"origin-[var(--radix-dropdown-menu-content-transform-origin)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -76,7 +100,29 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
// MD3 menu item - minimum 48dp height for touch
|
||||
"relative flex items-center gap-3",
|
||||
"min-h-[48px] px-3 py-2",
|
||||
"text-sm font-normal",
|
||||
"rounded-md cursor-pointer select-none outline-none",
|
||||
// State layer
|
||||
"transition-colors duration-150",
|
||||
// Default colors
|
||||
"text-[hsl(var(--popover-foreground))]",
|
||||
// Hover/focus state
|
||||
"hover:bg-[hsl(var(--muted))]",
|
||||
"focus:bg-[hsl(var(--muted))]",
|
||||
// Destructive variant
|
||||
"data-[variant=destructive]:text-destructive",
|
||||
"data-[variant=destructive]:hover:bg-destructive/10",
|
||||
"data-[variant=destructive]:focus:bg-destructive/10",
|
||||
// Disabled state
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-[0.38]",
|
||||
// Inset for items without icons
|
||||
"data-[inset]:pl-10",
|
||||
// Icon styling
|
||||
"[&_svg]:size-5 [&_svg]:shrink-0 [&_svg]:text-[hsl(var(--muted-foreground))]",
|
||||
"[&_svg]:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -94,15 +140,24 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
// MD3 checkbox item
|
||||
"relative flex items-center gap-3",
|
||||
"min-h-[48px] pl-10 pr-3 py-2",
|
||||
"text-sm font-normal",
|
||||
"rounded-md cursor-pointer select-none outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-[hsl(var(--muted))]",
|
||||
"focus:bg-[hsl(var(--muted))]",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-[0.38]",
|
||||
"[&_svg]:size-5 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<span className="absolute left-3 flex size-5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<CheckIcon className="size-5 text-primary" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -130,14 +185,23 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
// MD3 radio item
|
||||
"relative flex items-center gap-3",
|
||||
"min-h-[48px] pl-10 pr-3 py-2",
|
||||
"text-sm font-normal",
|
||||
"rounded-md cursor-pointer select-none outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-[hsl(var(--muted))]",
|
||||
"focus:bg-[hsl(var(--muted))]",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-[0.38]",
|
||||
"[&_svg]:size-5 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<span className="absolute left-3 flex size-5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
<CircleIcon className="size-2 fill-primary text-primary" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -157,7 +221,10 @@ function DropdownMenuLabel({
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
// MD3 label style
|
||||
"px-3 py-2 text-xs font-medium uppercase tracking-wider",
|
||||
"text-[hsl(var(--muted-foreground))]",
|
||||
"data-[inset]:pl-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -172,7 +239,7 @@ function DropdownMenuSeparator({
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
className={cn("my-1 h-px bg-[hsl(var(--border))]", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -186,7 +253,7 @@ function DropdownMenuShortcut({
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
"ml-auto text-xs text-[hsl(var(--muted-foreground))] tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -213,13 +280,22 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
// MD3 sub-menu trigger
|
||||
"relative flex items-center gap-3",
|
||||
"min-h-[48px] px-3 py-2",
|
||||
"text-sm font-normal",
|
||||
"rounded-md cursor-pointer select-none outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:bg-[hsl(var(--muted))]",
|
||||
"focus:bg-[hsl(var(--muted))]",
|
||||
"data-[state=open]:bg-[hsl(var(--muted))]",
|
||||
"data-[inset]:pl-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
<ChevronRightIcon className="ml-auto size-5 text-[hsl(var(--muted-foreground))]" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
@@ -232,7 +308,17 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
// MD3 sub-menu surface
|
||||
"bg-[hsl(var(--popover))] text-[hsl(var(--popover-foreground))]",
|
||||
"z-50 min-w-[180px] overflow-hidden rounded-lg border-0 p-1",
|
||||
"shadow-lg",
|
||||
// Animations
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"origin-[var(--radix-dropdown-menu-content-transform-origin)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
172
src/components/ui/fab.tsx
Normal file
172
src/components/ui/fab.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { ComponentProps, forwardRef } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Material Design 3 Floating Action Button (FAB)
|
||||
*
|
||||
* Variants:
|
||||
* - primary: Primary container color (default)
|
||||
* - secondary: Secondary container color
|
||||
* - tertiary: Tertiary container color
|
||||
* - surface: Surface container color
|
||||
*
|
||||
* Sizes:
|
||||
* - small: 40x40 (MD3 Small FAB)
|
||||
* - default: 56x56 (MD3 FAB)
|
||||
* - large: 96x96 (MD3 Large FAB)
|
||||
*/
|
||||
const fabVariants = cva(
|
||||
// Base styles
|
||||
[
|
||||
"inline-flex items-center justify-center",
|
||||
"rounded-2xl font-medium",
|
||||
"transition-all duration-200",
|
||||
"disabled:pointer-events-none disabled:opacity-[0.38]",
|
||||
"[&_svg]:pointer-events-none",
|
||||
"shrink-0 [&_svg]:shrink-0",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"relative overflow-hidden select-none",
|
||||
"shadow-lg hover:shadow-xl active:shadow-md",
|
||||
// State layer
|
||||
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-current before:opacity-0 before:transition-opacity before:duration-200",
|
||||
"hover:before:opacity-[0.08] focus-visible:before:opacity-[0.12] active:before:opacity-[0.12]",
|
||||
].join(" "),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// Primary container (default)
|
||||
primary: [
|
||||
"bg-primary text-primary-foreground",
|
||||
"focus-visible:ring-primary",
|
||||
].join(" "),
|
||||
|
||||
// Secondary container
|
||||
secondary: [
|
||||
"bg-secondary text-secondary-foreground",
|
||||
"focus-visible:ring-secondary",
|
||||
].join(" "),
|
||||
|
||||
// Tertiary container
|
||||
tertiary: [
|
||||
"bg-accent text-accent-foreground",
|
||||
"focus-visible:ring-accent",
|
||||
].join(" "),
|
||||
|
||||
// Surface container
|
||||
surface: [
|
||||
"bg-card text-primary",
|
||||
"focus-visible:ring-primary",
|
||||
].join(" "),
|
||||
},
|
||||
size: {
|
||||
// MD3 Small FAB - 40x40
|
||||
small: "size-10 rounded-xl [&_svg]:size-5",
|
||||
// MD3 FAB - 56x56
|
||||
default: "size-14 rounded-2xl [&_svg]:size-6",
|
||||
// MD3 Large FAB - 96x96
|
||||
large: "size-24 rounded-[28px] [&_svg]:size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "primary",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface FABProps
|
||||
extends ComponentProps<"button">,
|
||||
VariantProps<typeof fabVariants> {
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const FAB = forwardRef<HTMLButtonElement, FABProps>(
|
||||
({ className, variant, size, icon, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-slot="fab"
|
||||
className={cn(fabVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
FAB.displayName = "FAB"
|
||||
|
||||
/**
|
||||
* Extended FAB with label
|
||||
*/
|
||||
interface ExtendedFABProps
|
||||
extends ComponentProps<"button">,
|
||||
VariantProps<typeof fabVariants> {
|
||||
icon?: React.ReactNode
|
||||
label: string
|
||||
}
|
||||
|
||||
const extendedFabVariants = cva(
|
||||
// Base styles
|
||||
[
|
||||
"inline-flex items-center justify-center gap-3",
|
||||
"h-14 px-4 rounded-2xl font-medium text-sm",
|
||||
"transition-all duration-200",
|
||||
"disabled:pointer-events-none disabled:opacity-[0.38]",
|
||||
"[&_svg]:pointer-events-none [&_svg]:size-6",
|
||||
"shrink-0 [&_svg]:shrink-0",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"relative overflow-hidden select-none",
|
||||
"shadow-lg hover:shadow-xl active:shadow-md",
|
||||
// State layer
|
||||
"before:absolute before:inset-0 before:rounded-[inherit] before:bg-current before:opacity-0 before:transition-opacity before:duration-200",
|
||||
"hover:before:opacity-[0.08] focus-visible:before:opacity-[0.12] active:before:opacity-[0.12]",
|
||||
].join(" "),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: [
|
||||
"bg-primary text-primary-foreground",
|
||||
"focus-visible:ring-primary",
|
||||
].join(" "),
|
||||
secondary: [
|
||||
"bg-secondary text-secondary-foreground",
|
||||
"focus-visible:ring-secondary",
|
||||
].join(" "),
|
||||
tertiary: [
|
||||
"bg-accent text-accent-foreground",
|
||||
"focus-visible:ring-accent",
|
||||
].join(" "),
|
||||
surface: [
|
||||
"bg-card text-primary",
|
||||
"focus-visible:ring-primary",
|
||||
].join(" "),
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "primary",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const ExtendedFAB = forwardRef<HTMLButtonElement, ExtendedFABProps>(
|
||||
({ className, variant, icon, label, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-slot="extended-fab"
|
||||
className={cn(extendedFabVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
ExtendedFAB.displayName = "ExtendedFAB"
|
||||
|
||||
export { FAB, ExtendedFAB, fabVariants, extendedFabVariants }
|
||||
@@ -1,16 +1,42 @@
|
||||
import { ComponentProps } from "react"
|
||||
import { ComponentProps, forwardRef, useId, useState } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Material Design 3 Text Field Component
|
||||
*
|
||||
* Standard input with MD3 styling including:
|
||||
* - Filled and outlined variants
|
||||
* - Proper focus states with animated indicator
|
||||
* - Support for leading/trailing icons
|
||||
* - Helper text and error states
|
||||
*/
|
||||
function Input({ className, type, ...props }: ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
// Base styles
|
||||
"flex h-14 w-full min-w-0 rounded-t-[4px] rounded-b-none",
|
||||
"bg-[hsl(var(--muted))] px-4 pt-5 pb-2",
|
||||
"text-base text-foreground",
|
||||
"border-0 border-b-2 border-[hsl(var(--border))]",
|
||||
"transition-all duration-200",
|
||||
"outline-none",
|
||||
// Placeholder
|
||||
"placeholder:text-muted-foreground placeholder:opacity-0",
|
||||
"focus:placeholder:opacity-100",
|
||||
// Focus state - MD3 style active indicator
|
||||
"focus:border-b-primary focus:bg-[hsl(var(--muted))]/80",
|
||||
// File input styles
|
||||
"file:text-foreground file:inline-flex file:h-8 file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
// Selection
|
||||
"selection:bg-primary/30 selection:text-foreground",
|
||||
// Disabled state
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-[0.38]",
|
||||
// Error state
|
||||
"aria-invalid:border-b-destructive aria-invalid:focus:border-b-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -18,4 +44,172 @@ function Input({ className, type, ...props }: ComponentProps<"input">) {
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
/**
|
||||
* MD3 Outlined Text Field
|
||||
*/
|
||||
function InputOutlined({ className, type, ...props }: ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input-outlined"
|
||||
className={cn(
|
||||
// Base styles
|
||||
"flex h-14 w-full min-w-0 rounded-[4px]",
|
||||
"bg-transparent px-4 py-4",
|
||||
"text-base text-foreground",
|
||||
"border border-[hsl(var(--border))]",
|
||||
"transition-all duration-200",
|
||||
"outline-none",
|
||||
// Placeholder
|
||||
"placeholder:text-muted-foreground",
|
||||
// Focus state
|
||||
"focus:border-2 focus:border-primary",
|
||||
// Selection
|
||||
"selection:bg-primary/30 selection:text-foreground",
|
||||
// Disabled state
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-[0.38]",
|
||||
// Error state
|
||||
"aria-invalid:border-destructive aria-invalid:focus:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* MD3 Text Field with Floating Label
|
||||
*/
|
||||
interface TextFieldProps extends ComponentProps<"input"> {
|
||||
label: string
|
||||
helperText?: string
|
||||
error?: boolean
|
||||
errorText?: string
|
||||
leadingIcon?: React.ReactNode
|
||||
trailingIcon?: React.ReactNode
|
||||
variant?: "filled" | "outlined"
|
||||
}
|
||||
|
||||
const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
label,
|
||||
helperText,
|
||||
error,
|
||||
errorText,
|
||||
leadingIcon,
|
||||
trailingIcon,
|
||||
variant = "filled",
|
||||
id,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const generatedId = useId()
|
||||
const inputId = id || generatedId
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [hasValue, setHasValue] = useState(
|
||||
Boolean(props.value || props.defaultValue)
|
||||
)
|
||||
|
||||
const isLabelFloating = isFocused || hasValue
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(true)
|
||||
props.onFocus?.(e)
|
||||
}
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(false)
|
||||
props.onBlur?.(e)
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setHasValue(Boolean(e.target.value))
|
||||
props.onChange?.(e)
|
||||
}
|
||||
|
||||
const isFilled = variant === "filled"
|
||||
|
||||
return (
|
||||
<div className={cn("relative w-full", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center",
|
||||
isFilled
|
||||
? "rounded-t-[4px] rounded-b-none bg-[hsl(var(--muted))]"
|
||||
: "rounded-[4px] bg-transparent",
|
||||
isFilled
|
||||
? "border-0 border-b-2"
|
||||
: "border",
|
||||
error
|
||||
? "border-destructive"
|
||||
: isFocused
|
||||
? isFilled
|
||||
? "border-b-primary"
|
||||
: "border-2 border-primary"
|
||||
: "border-[hsl(var(--border))]",
|
||||
"transition-all duration-200"
|
||||
)}
|
||||
>
|
||||
{leadingIcon && (
|
||||
<span className="pl-3 text-muted-foreground">{leadingIcon}</span>
|
||||
)}
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
data-slot="text-field"
|
||||
className={cn(
|
||||
"peer w-full bg-transparent outline-none",
|
||||
"h-14 px-4 pt-5 pb-2 text-base text-foreground",
|
||||
"placeholder:text-transparent focus:placeholder:text-muted-foreground",
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-[0.38]",
|
||||
leadingIcon && "pl-2",
|
||||
trailingIcon && "pr-2"
|
||||
)}
|
||||
placeholder={label}
|
||||
aria-invalid={error}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={cn(
|
||||
"absolute left-4 transition-all duration-200 pointer-events-none",
|
||||
"text-muted-foreground",
|
||||
isLabelFloating
|
||||
? "top-2 text-xs"
|
||||
: "top-1/2 -translate-y-1/2 text-base",
|
||||
isFocused && !error && "text-primary",
|
||||
error && "text-destructive",
|
||||
leadingIcon && "left-12"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
{trailingIcon && (
|
||||
<span className="pr-3 text-muted-foreground">{trailingIcon}</span>
|
||||
)}
|
||||
</div>
|
||||
{(helperText || errorText) && (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 px-4 text-xs",
|
||||
error ? "text-destructive" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{error ? errorText : helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
TextField.displayName = "TextField"
|
||||
|
||||
export { Input, InputOutlined, TextField }
|
||||
|
||||
@@ -5,6 +5,15 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Material Design 3 Popover
|
||||
*
|
||||
* Android-style popover with:
|
||||
* - Proper elevation and shadow
|
||||
* - Smooth animations
|
||||
* - Accessible focus management
|
||||
*/
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
@@ -20,7 +29,7 @@ function PopoverTrigger({
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
@@ -30,7 +39,23 @@ function PopoverContent({
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
// MD3 surface container high
|
||||
"bg-[hsl(var(--popover))] text-[hsl(var(--popover-foreground))]",
|
||||
// Shape and shadow
|
||||
"z-50 w-72 rounded-xl border-0 p-4",
|
||||
"shadow-lg",
|
||||
// Animations
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2",
|
||||
"data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2",
|
||||
"data-[side=top]:slide-in-from-bottom-2",
|
||||
// Transform origin
|
||||
"origin-[var(--radix-popover-content-transform-origin)]",
|
||||
// Focus
|
||||
"outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -45,4 +70,29 @@ function PopoverAnchor({
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
/**
|
||||
* PopoverClose - Close button for popover
|
||||
*/
|
||||
function PopoverClose({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof PopoverPrimitive.Close>) {
|
||||
return (
|
||||
<PopoverPrimitive.Close
|
||||
data-slot="popover-close"
|
||||
className={cn(
|
||||
"absolute right-2 top-2",
|
||||
"flex items-center justify-center",
|
||||
"size-8 rounded-full",
|
||||
"text-[hsl(var(--muted-foreground))]",
|
||||
"hover:bg-[hsl(var(--muted))]",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary",
|
||||
"transition-colors duration-150",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose }
|
||||
|
||||
@@ -5,6 +5,15 @@ import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Material Design 3 Switch Component
|
||||
*
|
||||
* Android-style toggle switch with:
|
||||
* - 52x32 track (MD3 spec)
|
||||
* - 24x24 handle when unchecked, 28x28 when checked
|
||||
* - State layer on handle
|
||||
* - Proper focus ring
|
||||
*/
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
@@ -13,7 +22,20 @@ function Switch({
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
// Track dimensions (MD3: 52x32)
|
||||
"inline-flex h-8 w-[52px] shrink-0 items-center rounded-full",
|
||||
"border-2 border-transparent",
|
||||
"transition-colors duration-200",
|
||||
"outline-none cursor-pointer",
|
||||
// Unchecked track
|
||||
"data-[state=unchecked]:bg-muted",
|
||||
"data-[state=unchecked]:border-border",
|
||||
// Checked track
|
||||
"data-[state=checked]:bg-primary",
|
||||
// Focus state
|
||||
"focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
// Disabled state
|
||||
"disabled:cursor-not-allowed disabled:opacity-[0.38]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -21,7 +43,23 @@ function Switch({
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
// Thumb base
|
||||
"pointer-events-none block rounded-full",
|
||||
"shadow-md",
|
||||
"transition-all duration-200",
|
||||
// Unchecked: smaller thumb (24x24), positioned left
|
||||
"data-[state=unchecked]:size-6",
|
||||
"data-[state=unchecked]:translate-x-0.5",
|
||||
"data-[state=unchecked]:bg-muted-foreground",
|
||||
// Checked: larger thumb (28x28), positioned right
|
||||
"data-[state=checked]:size-7",
|
||||
"data-[state=checked]:translate-x-[22px]",
|
||||
"data-[state=checked]:bg-primary-foreground",
|
||||
// State layer effect on hover (via parent)
|
||||
"relative",
|
||||
"before:absolute before:inset-[-8px] before:rounded-full",
|
||||
"before:bg-current before:opacity-0 before:transition-opacity",
|
||||
"group-hover:before:opacity-[0.08]"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
|
||||
184
src/components/ui/top-app-bar.tsx
Normal file
184
src/components/ui/top-app-bar.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import { ComponentProps, forwardRef } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Material Design 3 Top App Bar
|
||||
*
|
||||
* Variants:
|
||||
* - center-aligned: Logo/title centered (default)
|
||||
* - small: Standard top app bar
|
||||
* - medium: Medium with larger title
|
||||
* - large: Large with prominent title
|
||||
*/
|
||||
|
||||
const topAppBarVariants = cva(
|
||||
// Base styles
|
||||
[
|
||||
"w-full",
|
||||
"bg-[hsl(var(--card))]",
|
||||
"transition-all duration-200",
|
||||
].join(" "),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
"center-aligned": "h-16",
|
||||
small: "h-16",
|
||||
medium: "h-28",
|
||||
large: "h-36",
|
||||
},
|
||||
scrolled: {
|
||||
true: "shadow-md bg-[hsl(var(--card))]/95 backdrop-blur-sm",
|
||||
false: "shadow-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "small",
|
||||
scrolled: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface TopAppBarProps
|
||||
extends ComponentProps<"header">,
|
||||
VariantProps<typeof topAppBarVariants> {
|
||||
navigationIcon?: React.ReactNode
|
||||
onNavigationClick?: () => void
|
||||
title?: string
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
const TopAppBar = forwardRef<HTMLElement, TopAppBarProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant,
|
||||
scrolled,
|
||||
navigationIcon,
|
||||
onNavigationClick,
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isCenterAligned = variant === "center-aligned"
|
||||
const isMediumOrLarge = variant === "medium" || variant === "large"
|
||||
|
||||
return (
|
||||
<header
|
||||
ref={ref}
|
||||
data-slot="top-app-bar"
|
||||
className={cn(topAppBarVariants({ variant, scrolled }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center h-16 px-1",
|
||||
isCenterAligned && "justify-between"
|
||||
)}
|
||||
>
|
||||
{/* Leading navigation icon */}
|
||||
{navigationIcon && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigationClick}
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
"size-12 rounded-full",
|
||||
"text-foreground",
|
||||
"hover:bg-foreground/[0.08]",
|
||||
"focus-visible:bg-foreground/[0.12]",
|
||||
"active:bg-foreground/[0.12]",
|
||||
"transition-colors duration-200",
|
||||
"outline-none"
|
||||
)}
|
||||
>
|
||||
{navigationIcon}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Title - for small and center-aligned */}
|
||||
{!isMediumOrLarge && title && (
|
||||
<h1
|
||||
className={cn(
|
||||
"text-lg font-normal text-foreground",
|
||||
"truncate",
|
||||
isCenterAligned
|
||||
? "absolute left-1/2 -translate-x-1/2"
|
||||
: "ml-2 flex-1"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* Trailing actions */}
|
||||
{actions && (
|
||||
<div className={cn("flex items-center gap-1", !isCenterAligned && "ml-auto")}>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title for medium and large variants */}
|
||||
{isMediumOrLarge && title && (
|
||||
<div className="px-4 pb-4">
|
||||
<h1
|
||||
className={cn(
|
||||
"text-foreground font-normal",
|
||||
variant === "medium" && "text-2xl",
|
||||
variant === "large" && "text-3xl"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
)
|
||||
TopAppBar.displayName = "TopAppBar"
|
||||
|
||||
/**
|
||||
* Top App Bar Action Button
|
||||
*/
|
||||
interface TopAppBarActionProps extends ComponentProps<"button"> {
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const TopAppBarAction = forwardRef<HTMLButtonElement, TopAppBarActionProps>(
|
||||
({ className, icon, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-slot="top-app-bar-action"
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
"size-12 rounded-full",
|
||||
"text-foreground",
|
||||
"hover:bg-foreground/[0.08]",
|
||||
"focus-visible:bg-foreground/[0.12]",
|
||||
"active:bg-foreground/[0.12]",
|
||||
"transition-colors duration-200",
|
||||
"outline-none",
|
||||
"[&_svg]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
TopAppBarAction.displayName = "TopAppBarAction"
|
||||
|
||||
export { TopAppBar, TopAppBarAction, topAppBarVariants }
|
||||
@@ -2,8 +2,14 @@ import { loadPyodide, PyodideInterface } from 'pyodide'
|
||||
|
||||
let pyodideInstance: PyodideInterface | null = null
|
||||
let pyodideLoading: Promise<PyodideInterface> | null = null
|
||||
let initializationError: Error | null = null
|
||||
|
||||
export async function getPyodide(): Promise<PyodideInterface> {
|
||||
// If we had an initialization error, throw it
|
||||
if (initializationError) {
|
||||
throw initializationError
|
||||
}
|
||||
|
||||
if (pyodideInstance) {
|
||||
return pyodideInstance
|
||||
}
|
||||
@@ -13,10 +19,15 @@ export async function getPyodide(): Promise<PyodideInterface> {
|
||||
}
|
||||
|
||||
pyodideLoading = loadPyodide({
|
||||
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.29.1/full/',
|
||||
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.27.0/full/',
|
||||
}).then((pyodide) => {
|
||||
pyodideInstance = pyodide
|
||||
initializationError = null
|
||||
return pyodide
|
||||
}).catch((err) => {
|
||||
initializationError = err instanceof Error ? err : new Error(String(err))
|
||||
pyodideLoading = null
|
||||
throw initializationError
|
||||
})
|
||||
|
||||
return pyodideLoading
|
||||
@@ -25,7 +36,8 @@ export async function getPyodide(): Promise<PyodideInterface> {
|
||||
export async function runPythonCode(code: string): Promise<{ output?: string; error?: string }> {
|
||||
try {
|
||||
const pyodide = await getPyodide()
|
||||
|
||||
|
||||
// Reset stdout/stderr for each run
|
||||
pyodide.runPython(`
|
||||
import sys
|
||||
from io import StringIO
|
||||
@@ -33,19 +45,39 @@ sys.stdout = StringIO()
|
||||
sys.stderr = StringIO()
|
||||
`)
|
||||
|
||||
const result = pyodide.runPython(code)
|
||||
const stdout = pyodide.runPython('sys.stdout.getvalue()')
|
||||
|
||||
let output = stdout || ''
|
||||
if (result !== undefined && result !== null) {
|
||||
output += (output ? '\n' : '') + String(result)
|
||||
}
|
||||
try {
|
||||
const result = pyodide.runPython(code)
|
||||
const stdout = pyodide.runPython('sys.stdout.getvalue()')
|
||||
const stderr = pyodide.runPython('sys.stderr.getvalue()')
|
||||
|
||||
return { output: output || '(no output)' }
|
||||
let output = stdout || ''
|
||||
if (stderr) {
|
||||
output += (output ? '\n' : '') + stderr
|
||||
}
|
||||
if (result !== undefined && result !== null && String(result) !== 'None') {
|
||||
output += (output ? '\n' : '') + String(result)
|
||||
}
|
||||
|
||||
return { output: output || '(no output)' }
|
||||
} catch (runErr) {
|
||||
// Get any stderr output that might have been captured
|
||||
let stderr = ''
|
||||
try {
|
||||
stderr = pyodide.runPython('sys.stderr.getvalue()')
|
||||
} catch {
|
||||
// Ignore errors getting stderr
|
||||
}
|
||||
|
||||
const errorMessage = runErr instanceof Error ? runErr.message : String(runErr)
|
||||
return {
|
||||
output: '',
|
||||
error: stderr ? `${stderr}\n${errorMessage}` : errorMessage
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
output: '',
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
error: `Python environment error: ${err instanceof Error ? err.message : String(err)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +94,7 @@ export async function runPythonCodeInteractive(
|
||||
): Promise<void> {
|
||||
const pyodide = await getPyodide()
|
||||
|
||||
// Set up interactive stdout/stderr handlers
|
||||
pyodide.runPython(`
|
||||
import sys
|
||||
from io import StringIO
|
||||
@@ -70,7 +103,7 @@ class InteractiveStdout:
|
||||
def __init__(self, callback):
|
||||
self.callback = callback
|
||||
self.buffer = ""
|
||||
|
||||
|
||||
def write(self, text):
|
||||
self.buffer += text
|
||||
if "\\n" in text:
|
||||
@@ -80,7 +113,7 @@ class InteractiveStdout:
|
||||
self.callback(line)
|
||||
self.buffer = lines[-1]
|
||||
return len(text)
|
||||
|
||||
|
||||
def flush(self):
|
||||
if self.buffer:
|
||||
self.callback(self.buffer)
|
||||
@@ -90,7 +123,7 @@ class InteractiveStderr:
|
||||
def __init__(self, callback):
|
||||
self.callback = callback
|
||||
self.buffer = ""
|
||||
|
||||
|
||||
def write(self, text):
|
||||
self.buffer += text
|
||||
if "\\n" in text:
|
||||
@@ -100,7 +133,7 @@ class InteractiveStderr:
|
||||
self.callback(line)
|
||||
self.buffer = lines[-1]
|
||||
return len(text)
|
||||
|
||||
|
||||
def flush(self):
|
||||
if self.buffer:
|
||||
self.callback(self.buffer)
|
||||
@@ -137,33 +170,38 @@ sys.stderr = InteractiveStderr(__error_callback__)
|
||||
|
||||
pyodide.globals.set('js_input_handler', customInput)
|
||||
|
||||
// Set up custom input function using asyncio.run() which is the modern approach
|
||||
await pyodide.runPythonAsync(`
|
||||
import builtins
|
||||
from pyodide.ffi import to_js
|
||||
import asyncio
|
||||
|
||||
def custom_input(prompt=""):
|
||||
async def async_input(prompt=""):
|
||||
import sys
|
||||
sys.stdout.write(prompt)
|
||||
sys.stdout.flush()
|
||||
|
||||
async def get_input():
|
||||
result = await js_input_handler(prompt)
|
||||
return result
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
result = await js_input_handler(prompt)
|
||||
return result
|
||||
|
||||
def custom_input(prompt=""):
|
||||
import asyncio
|
||||
try:
|
||||
# Try to get the running loop (works in async context)
|
||||
loop = asyncio.get_running_loop()
|
||||
# If we're in an async context, we need to use a different approach
|
||||
import pyodide.webloop
|
||||
return pyodide.webloop.WebLoop().run_until_complete(get_input())
|
||||
else:
|
||||
return loop.run_until_complete(get_input())
|
||||
future = asyncio.ensure_future(async_input(prompt))
|
||||
return pyodide.webloop.WebLoop().run_until_complete(future)
|
||||
except RuntimeError:
|
||||
# No running loop, create a new one
|
||||
return asyncio.run(async_input(prompt))
|
||||
|
||||
builtins.input = custom_input
|
||||
`)
|
||||
|
||||
try {
|
||||
await pyodide.runPythonAsync(code)
|
||||
|
||||
|
||||
pyodide.runPython('sys.stdout.flush()')
|
||||
pyodide.runPython('sys.stderr.flush()')
|
||||
} catch (err) {
|
||||
@@ -177,3 +215,13 @@ builtins.input = custom_input
|
||||
export function isPyodideReady(): boolean {
|
||||
return pyodideInstance !== null
|
||||
}
|
||||
|
||||
export function getPyodideError(): Error | null {
|
||||
return initializationError
|
||||
}
|
||||
|
||||
export function resetPyodide(): void {
|
||||
pyodideInstance = null
|
||||
pyodideLoading = null
|
||||
initializationError = null
|
||||
}
|
||||
|
||||
@@ -1,49 +1,370 @@
|
||||
// Color Variables - Material Design 3 (Material You) inspired
|
||||
$background: hsl(222.2, 84%, 4.9%);
|
||||
$foreground: hsl(210, 15%, 88%); // MD3 on-surface
|
||||
$card: hsl(222.2, 40%, 8%); // MD3 surface-container
|
||||
$card-foreground: hsl(210, 15%, 88%);
|
||||
$popover: hsl(222.2, 40%, 10%); // MD3 surface-container-high
|
||||
$popover-foreground: hsl(210, 15%, 88%);
|
||||
$primary: hsl(260, 45%, 55%); // MD3 primary - softer purple
|
||||
$primary-foreground: hsl(210, 15%, 95%);
|
||||
$secondary: hsl(217.2, 25%, 20%); // MD3 secondary-container
|
||||
$secondary-foreground: hsl(210, 15%, 88%);
|
||||
$muted: hsl(217.2, 25%, 22%);
|
||||
$muted-foreground: hsl(215, 12%, 60%); // MD3 on-surface-variant
|
||||
$accent: hsl(195, 50%, 50%); // MD3 tertiary - balanced cyan
|
||||
$accent-foreground: hsl(222.2, 84%, 4.9%);
|
||||
$destructive: hsl(0, 50%, 50%); // MD3 error
|
||||
$destructive-foreground: hsl(210, 15%, 95%);
|
||||
$border: hsl(217.2, 20%, 25%); // MD3 outline
|
||||
$input: hsl(217.2, 25%, 18%);
|
||||
$ring: hsl(195, 50%, 50%); // Match accent
|
||||
// ============================================
|
||||
// Material Design 3 (Material You) Color System
|
||||
// Android Smartphone UI Theme
|
||||
// ============================================
|
||||
|
||||
// Size scale variables (for direct use in component styles)
|
||||
// === MD3 Primary Tonal Palette ===
|
||||
// Purple/Violet primary color with full tonal range
|
||||
$md3-primary-0: hsl(260, 50%, 0%);
|
||||
$md3-primary-10: hsl(260, 50%, 10%);
|
||||
$md3-primary-20: hsl(260, 50%, 20%);
|
||||
$md3-primary-25: hsl(260, 50%, 25%);
|
||||
$md3-primary-30: hsl(260, 50%, 30%);
|
||||
$md3-primary-35: hsl(260, 50%, 35%);
|
||||
$md3-primary-40: hsl(260, 50%, 40%);
|
||||
$md3-primary-50: hsl(260, 50%, 50%);
|
||||
$md3-primary-60: hsl(260, 50%, 60%);
|
||||
$md3-primary-70: hsl(260, 50%, 70%);
|
||||
$md3-primary-80: hsl(260, 50%, 80%);
|
||||
$md3-primary-90: hsl(260, 50%, 90%);
|
||||
$md3-primary-95: hsl(260, 50%, 95%);
|
||||
$md3-primary-99: hsl(260, 50%, 99%);
|
||||
$md3-primary-100: hsl(260, 50%, 100%);
|
||||
|
||||
// === MD3 Secondary Tonal Palette ===
|
||||
// Desaturated purple for secondary elements
|
||||
$md3-secondary-0: hsl(260, 15%, 0%);
|
||||
$md3-secondary-10: hsl(260, 15%, 10%);
|
||||
$md3-secondary-20: hsl(260, 15%, 20%);
|
||||
$md3-secondary-25: hsl(260, 15%, 25%);
|
||||
$md3-secondary-30: hsl(260, 15%, 30%);
|
||||
$md3-secondary-35: hsl(260, 15%, 35%);
|
||||
$md3-secondary-40: hsl(260, 15%, 40%);
|
||||
$md3-secondary-50: hsl(260, 15%, 50%);
|
||||
$md3-secondary-60: hsl(260, 15%, 60%);
|
||||
$md3-secondary-70: hsl(260, 15%, 70%);
|
||||
$md3-secondary-80: hsl(260, 15%, 80%);
|
||||
$md3-secondary-90: hsl(260, 15%, 90%);
|
||||
$md3-secondary-95: hsl(260, 15%, 95%);
|
||||
$md3-secondary-99: hsl(260, 15%, 99%);
|
||||
$md3-secondary-100: hsl(260, 15%, 100%);
|
||||
|
||||
// === MD3 Tertiary Tonal Palette ===
|
||||
// Cyan/Teal for tertiary accents
|
||||
$md3-tertiary-0: hsl(180, 50%, 0%);
|
||||
$md3-tertiary-10: hsl(180, 50%, 10%);
|
||||
$md3-tertiary-20: hsl(180, 50%, 20%);
|
||||
$md3-tertiary-25: hsl(180, 50%, 25%);
|
||||
$md3-tertiary-30: hsl(180, 50%, 30%);
|
||||
$md3-tertiary-35: hsl(180, 50%, 35%);
|
||||
$md3-tertiary-40: hsl(180, 50%, 40%);
|
||||
$md3-tertiary-50: hsl(180, 50%, 50%);
|
||||
$md3-tertiary-60: hsl(180, 50%, 60%);
|
||||
$md3-tertiary-70: hsl(180, 50%, 70%);
|
||||
$md3-tertiary-80: hsl(180, 50%, 80%);
|
||||
$md3-tertiary-90: hsl(180, 50%, 90%);
|
||||
$md3-tertiary-95: hsl(180, 50%, 95%);
|
||||
$md3-tertiary-99: hsl(180, 50%, 99%);
|
||||
$md3-tertiary-100: hsl(180, 50%, 100%);
|
||||
|
||||
// === MD3 Error Tonal Palette ===
|
||||
$md3-error-0: hsl(0, 75%, 0%);
|
||||
$md3-error-10: hsl(0, 75%, 10%);
|
||||
$md3-error-20: hsl(0, 75%, 20%);
|
||||
$md3-error-30: hsl(0, 75%, 30%);
|
||||
$md3-error-40: hsl(0, 75%, 40%);
|
||||
$md3-error-50: hsl(0, 75%, 50%);
|
||||
$md3-error-60: hsl(0, 75%, 60%);
|
||||
$md3-error-70: hsl(0, 75%, 70%);
|
||||
$md3-error-80: hsl(0, 75%, 80%);
|
||||
$md3-error-90: hsl(0, 75%, 90%);
|
||||
$md3-error-95: hsl(0, 75%, 95%);
|
||||
$md3-error-100: hsl(0, 75%, 100%);
|
||||
|
||||
// === MD3 Neutral Tonal Palette ===
|
||||
// For surfaces and backgrounds
|
||||
$md3-neutral-0: hsl(260, 10%, 0%);
|
||||
$md3-neutral-4: hsl(260, 10%, 4%);
|
||||
$md3-neutral-6: hsl(260, 10%, 6%);
|
||||
$md3-neutral-10: hsl(260, 10%, 10%);
|
||||
$md3-neutral-12: hsl(260, 10%, 12%);
|
||||
$md3-neutral-17: hsl(260, 10%, 17%);
|
||||
$md3-neutral-20: hsl(260, 10%, 20%);
|
||||
$md3-neutral-22: hsl(260, 10%, 22%);
|
||||
$md3-neutral-24: hsl(260, 10%, 24%);
|
||||
$md3-neutral-25: hsl(260, 10%, 25%);
|
||||
$md3-neutral-30: hsl(260, 10%, 30%);
|
||||
$md3-neutral-35: hsl(260, 10%, 35%);
|
||||
$md3-neutral-40: hsl(260, 10%, 40%);
|
||||
$md3-neutral-50: hsl(260, 10%, 50%);
|
||||
$md3-neutral-60: hsl(260, 10%, 60%);
|
||||
$md3-neutral-70: hsl(260, 10%, 70%);
|
||||
$md3-neutral-80: hsl(260, 10%, 80%);
|
||||
$md3-neutral-87: hsl(260, 10%, 87%);
|
||||
$md3-neutral-90: hsl(260, 10%, 90%);
|
||||
$md3-neutral-92: hsl(260, 10%, 92%);
|
||||
$md3-neutral-94: hsl(260, 10%, 94%);
|
||||
$md3-neutral-95: hsl(260, 10%, 95%);
|
||||
$md3-neutral-96: hsl(260, 10%, 96%);
|
||||
$md3-neutral-98: hsl(260, 10%, 98%);
|
||||
$md3-neutral-99: hsl(260, 10%, 99%);
|
||||
$md3-neutral-100: hsl(260, 10%, 100%);
|
||||
|
||||
// === MD3 Neutral Variant Tonal Palette ===
|
||||
// For outline and surface variants
|
||||
$md3-neutral-variant-0: hsl(260, 15%, 0%);
|
||||
$md3-neutral-variant-10: hsl(260, 15%, 10%);
|
||||
$md3-neutral-variant-20: hsl(260, 15%, 20%);
|
||||
$md3-neutral-variant-25: hsl(260, 15%, 25%);
|
||||
$md3-neutral-variant-30: hsl(260, 15%, 30%);
|
||||
$md3-neutral-variant-35: hsl(260, 15%, 35%);
|
||||
$md3-neutral-variant-40: hsl(260, 15%, 40%);
|
||||
$md3-neutral-variant-50: hsl(260, 15%, 50%);
|
||||
$md3-neutral-variant-60: hsl(260, 15%, 60%);
|
||||
$md3-neutral-variant-70: hsl(260, 15%, 70%);
|
||||
$md3-neutral-variant-80: hsl(260, 15%, 80%);
|
||||
$md3-neutral-variant-90: hsl(260, 15%, 90%);
|
||||
$md3-neutral-variant-95: hsl(260, 15%, 95%);
|
||||
$md3-neutral-variant-99: hsl(260, 15%, 99%);
|
||||
$md3-neutral-variant-100: hsl(260, 15%, 100%);
|
||||
|
||||
// === MD3 Semantic Colors (Success, Warning, Info) ===
|
||||
$md3-success-40: hsl(145, 60%, 35%);
|
||||
$md3-success-80: hsl(145, 60%, 75%);
|
||||
$md3-success-90: hsl(145, 60%, 90%);
|
||||
$md3-warning-40: hsl(40, 80%, 45%);
|
||||
$md3-warning-80: hsl(40, 80%, 75%);
|
||||
$md3-warning-90: hsl(40, 80%, 90%);
|
||||
$md3-info-40: hsl(210, 70%, 45%);
|
||||
$md3-info-80: hsl(210, 70%, 75%);
|
||||
$md3-info-90: hsl(210, 70%, 90%);
|
||||
|
||||
// ============================================
|
||||
// MD3 Dark Theme Color Scheme
|
||||
// ============================================
|
||||
|
||||
// Primary colors
|
||||
$primary: $md3-primary-80;
|
||||
$primary-foreground: $md3-primary-20;
|
||||
$on-primary: $md3-primary-20;
|
||||
$primary-container: $md3-primary-30;
|
||||
$on-primary-container: $md3-primary-90;
|
||||
|
||||
// Secondary colors
|
||||
$secondary: $md3-secondary-80;
|
||||
$secondary-foreground: $md3-secondary-20;
|
||||
$on-secondary: $md3-secondary-20;
|
||||
$secondary-container: $md3-secondary-30;
|
||||
$on-secondary-container: $md3-secondary-90;
|
||||
|
||||
// Tertiary colors
|
||||
$accent: $md3-tertiary-80;
|
||||
$accent-foreground: $md3-tertiary-20;
|
||||
$on-tertiary: $md3-tertiary-20;
|
||||
$tertiary-container: $md3-tertiary-30;
|
||||
$on-tertiary-container: $md3-tertiary-90;
|
||||
|
||||
// Error colors
|
||||
$destructive: $md3-error-80;
|
||||
$destructive-foreground: $md3-error-20;
|
||||
$on-error: $md3-error-20;
|
||||
$error-container: $md3-error-30;
|
||||
$on-error-container: $md3-error-90;
|
||||
|
||||
// Surface colors (MD3 Tonal Elevation)
|
||||
$background: $md3-neutral-6;
|
||||
$foreground: $md3-neutral-90;
|
||||
$on-background: $md3-neutral-90;
|
||||
|
||||
$surface: $md3-neutral-6;
|
||||
$on-surface: $md3-neutral-90;
|
||||
$surface-dim: $md3-neutral-6;
|
||||
$surface-bright: $md3-neutral-24;
|
||||
|
||||
// Surface containers for MD3 tonal elevation
|
||||
$surface-container-lowest: $md3-neutral-4;
|
||||
$surface-container-low: $md3-neutral-10;
|
||||
$surface-container: $md3-neutral-12;
|
||||
$surface-container-high: $md3-neutral-17;
|
||||
$surface-container-highest: $md3-neutral-22;
|
||||
|
||||
// Surface variant
|
||||
$surface-variant: $md3-neutral-variant-30;
|
||||
$on-surface-variant: $md3-neutral-variant-80;
|
||||
|
||||
// Outline colors
|
||||
$outline: $md3-neutral-variant-60;
|
||||
$outline-variant: $md3-neutral-variant-30;
|
||||
|
||||
// Legacy compatibility mappings
|
||||
$card: $surface-container-low;
|
||||
$card-foreground: $on-surface;
|
||||
$popover: $surface-container-high;
|
||||
$popover-foreground: $on-surface;
|
||||
$muted: $surface-container;
|
||||
$muted-foreground: $on-surface-variant;
|
||||
$border: $outline-variant;
|
||||
$input: $surface-container-highest;
|
||||
$ring: $primary;
|
||||
|
||||
// Inverse colors
|
||||
$inverse-surface: $md3-neutral-90;
|
||||
$inverse-on-surface: $md3-neutral-20;
|
||||
$inverse-primary: $md3-primary-40;
|
||||
|
||||
// Scrim and shadow
|
||||
$scrim: $md3-neutral-0;
|
||||
$shadow: $md3-neutral-0;
|
||||
|
||||
// State layer opacities (MD3 spec)
|
||||
$state-hover-opacity: 0.08;
|
||||
$state-focus-opacity: 0.12;
|
||||
$state-pressed-opacity: 0.12;
|
||||
$state-dragged-opacity: 0.16;
|
||||
$state-disabled-opacity: 0.38;
|
||||
$state-disabled-container-opacity: 0.12;
|
||||
|
||||
// ============================================
|
||||
// MD3 Typography Scale
|
||||
// ============================================
|
||||
|
||||
// Display
|
||||
$font-display-large: 3.5625rem; // 57px
|
||||
$font-display-medium: 2.8125rem; // 45px
|
||||
$font-display-small: 2.25rem; // 36px
|
||||
|
||||
// Headline
|
||||
$font-headline-large: 2rem; // 32px
|
||||
$font-headline-medium: 1.75rem; // 28px
|
||||
$font-headline-small: 1.5rem; // 24px
|
||||
|
||||
// Title
|
||||
$font-title-large: 1.375rem; // 22px
|
||||
$font-title-medium: 1rem; // 16px
|
||||
$font-title-small: 0.875rem; // 14px
|
||||
|
||||
// Body
|
||||
$font-body-large: 1rem; // 16px
|
||||
$font-body-medium: 0.875rem; // 14px
|
||||
$font-body-small: 0.75rem; // 12px
|
||||
|
||||
// Label
|
||||
$font-label-large: 0.875rem; // 14px
|
||||
$font-label-medium: 0.75rem; // 12px
|
||||
$font-label-small: 0.6875rem; // 11px
|
||||
|
||||
// Line heights for MD3
|
||||
$line-height-display-large: 4rem; // 64px
|
||||
$line-height-display-medium: 3.25rem; // 52px
|
||||
$line-height-display-small: 2.75rem; // 44px
|
||||
$line-height-headline-large: 2.5rem; // 40px
|
||||
$line-height-headline-medium: 2.25rem; // 36px
|
||||
$line-height-headline-small: 2rem; // 32px
|
||||
$line-height-title-large: 1.75rem; // 28px
|
||||
$line-height-title-medium: 1.5rem; // 24px
|
||||
$line-height-title-small: 1.25rem; // 20px
|
||||
$line-height-body-large: 1.5rem; // 24px
|
||||
$line-height-body-medium: 1.25rem; // 20px
|
||||
$line-height-body-small: 1rem; // 16px
|
||||
$line-height-label-large: 1.25rem; // 20px
|
||||
$line-height-label-medium: 1rem; // 16px
|
||||
$line-height-label-small: 1rem; // 16px
|
||||
|
||||
// Letter spacing for MD3
|
||||
$letter-spacing-display: -0.25px;
|
||||
$letter-spacing-headline: 0;
|
||||
$letter-spacing-title: 0;
|
||||
$letter-spacing-body: 0.25px;
|
||||
$letter-spacing-label: 0.1px;
|
||||
|
||||
// ============================================
|
||||
// MD3 Border Radius (Shape Scale)
|
||||
// ============================================
|
||||
$radius-factor: 1;
|
||||
$size-scale: 1;
|
||||
|
||||
$radius-none: 0;
|
||||
$radius-extra-small: calc(4px * $radius-factor); // MD3 extra-small
|
||||
$radius-small: calc(8px * $radius-factor); // MD3 small
|
||||
$radius-medium: calc(12px * $radius-factor); // MD3 medium
|
||||
$radius-large: calc(16px * $radius-factor); // MD3 large
|
||||
$radius-extra-large: calc(28px * $radius-factor); // MD3 extra-large
|
||||
$radius-full: 9999px;
|
||||
|
||||
// Legacy mappings
|
||||
$radius-sm: $radius-extra-small;
|
||||
$radius-md: $radius-small;
|
||||
$radius-lg: $radius-medium;
|
||||
$radius-xl: $radius-large;
|
||||
|
||||
// ============================================
|
||||
// MD3 Elevation System
|
||||
// ============================================
|
||||
|
||||
// Elevation shadows (with tonal overlay for dark theme)
|
||||
$elevation-1: 0 1px 2px rgba($shadow, 0.3), 0 1px 3px 1px rgba($shadow, 0.15);
|
||||
$elevation-2: 0 1px 2px rgba($shadow, 0.3), 0 2px 6px 2px rgba($shadow, 0.15);
|
||||
$elevation-3: 0 4px 8px 3px rgba($shadow, 0.15), 0 1px 3px rgba($shadow, 0.3);
|
||||
$elevation-4: 0 6px 10px 4px rgba($shadow, 0.15), 0 2px 3px rgba($shadow, 0.3);
|
||||
$elevation-5: 0 8px 12px 6px rgba($shadow, 0.15), 0 4px 4px rgba($shadow, 0.3);
|
||||
|
||||
// Tonal elevation overlays (for dark theme surfaces)
|
||||
$tonal-elevation-1: rgba($primary, 0.05);
|
||||
$tonal-elevation-2: rgba($primary, 0.08);
|
||||
$tonal-elevation-3: rgba($primary, 0.11);
|
||||
$tonal-elevation-4: rgba($primary, 0.12);
|
||||
$tonal-elevation-5: rgba($primary, 0.14);
|
||||
|
||||
// Legacy shadow mappings
|
||||
$shadow-accent: rgba($primary, 0.08);
|
||||
$shadow-accent-md: rgba($primary, 0.12);
|
||||
$shadow-focus: rgba($primary, 0.10);
|
||||
$accent-hover: $md3-tertiary-70;
|
||||
|
||||
// ============================================
|
||||
// MD3 Motion/Animation
|
||||
// ============================================
|
||||
|
||||
// Duration tokens
|
||||
$duration-short-1: 50ms;
|
||||
$duration-short-2: 100ms;
|
||||
$duration-short-3: 150ms;
|
||||
$duration-short-4: 200ms;
|
||||
$duration-medium-1: 250ms;
|
||||
$duration-medium-2: 300ms;
|
||||
$duration-medium-3: 350ms;
|
||||
$duration-medium-4: 400ms;
|
||||
$duration-long-1: 450ms;
|
||||
$duration-long-2: 500ms;
|
||||
$duration-long-3: 550ms;
|
||||
$duration-long-4: 600ms;
|
||||
$duration-extra-long-1: 700ms;
|
||||
$duration-extra-long-2: 800ms;
|
||||
$duration-extra-long-3: 900ms;
|
||||
$duration-extra-long-4: 1000ms;
|
||||
|
||||
// Easing tokens (MD3 curves)
|
||||
$easing-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
$easing-standard-decelerate: cubic-bezier(0, 0, 0, 1);
|
||||
$easing-standard-accelerate: cubic-bezier(0.3, 0, 1, 1);
|
||||
$easing-emphasized: cubic-bezier(0.2, 0, 0, 1);
|
||||
$easing-emphasized-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1);
|
||||
$easing-emphasized-accelerate: cubic-bezier(0.3, 0, 0.8, 0.15);
|
||||
$easing-legacy: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
// ============================================
|
||||
// Size Scale (4dp grid system)
|
||||
// ============================================
|
||||
$size-0: 0px;
|
||||
$size-px: 1px;
|
||||
$size-0-5: calc(0.125rem * $size-scale);
|
||||
$size-1: calc(0.25rem * $size-scale);
|
||||
$size-2: calc(0.5rem * $size-scale);
|
||||
$size-3: calc(0.75rem * $size-scale);
|
||||
$size-4: calc(1rem * $size-scale);
|
||||
$size-5: calc(1.25rem * $size-scale);
|
||||
$size-6: calc(1.5rem * $size-scale);
|
||||
$size-8: calc(2rem * $size-scale);
|
||||
$size-10: calc(2.5rem * $size-scale);
|
||||
$size-12: calc(3rem * $size-scale);
|
||||
$size-16: calc(4rem * $size-scale);
|
||||
$size-20: calc(5rem * $size-scale);
|
||||
$size-24: calc(6rem * $size-scale);
|
||||
$size-0-5: calc(0.125rem * $size-scale); // 2px
|
||||
$size-1: calc(0.25rem * $size-scale); // 4px
|
||||
$size-2: calc(0.5rem * $size-scale); // 8px
|
||||
$size-3: calc(0.75rem * $size-scale); // 12px
|
||||
$size-4: calc(1rem * $size-scale); // 16px
|
||||
$size-5: calc(1.25rem * $size-scale); // 20px
|
||||
$size-6: calc(1.5rem * $size-scale); // 24px
|
||||
$size-8: calc(2rem * $size-scale); // 32px
|
||||
$size-10: calc(2.5rem * $size-scale); // 40px
|
||||
$size-12: calc(3rem * $size-scale); // 48px
|
||||
$size-14: calc(3.5rem * $size-scale); // 56px
|
||||
$size-16: calc(4rem * $size-scale); // 64px
|
||||
$size-20: calc(5rem * $size-scale); // 80px
|
||||
$size-24: calc(6rem * $size-scale); // 96px
|
||||
|
||||
// Border radii variables (for direct use)
|
||||
$radius-factor: 1;
|
||||
$radius-sm: calc(2px * $radius-factor * $size-scale);
|
||||
$radius-md: calc(6px * $radius-factor * $size-scale);
|
||||
$radius-lg: calc(8px * $radius-factor * $size-scale);
|
||||
$radius-xl: calc(12px * $radius-factor * $size-scale);
|
||||
$radius-full: 9999px;
|
||||
// ============================================
|
||||
// Legacy Compatibility
|
||||
// ============================================
|
||||
|
||||
// Spacing Scale
|
||||
$spacing: (
|
||||
@@ -81,20 +402,20 @@ $spacing: (
|
||||
96: 24rem,
|
||||
);
|
||||
|
||||
// Border Radius
|
||||
// Border Radius (legacy map)
|
||||
$radius: (
|
||||
none: 0,
|
||||
sm: 0.125rem,
|
||||
base: 0.25rem,
|
||||
md: 0.375rem,
|
||||
lg: 0.5rem,
|
||||
xl: 0.75rem,
|
||||
2xl: 1rem,
|
||||
3xl: 1.5rem,
|
||||
sm: 0.25rem,
|
||||
base: 0.5rem,
|
||||
md: 0.75rem,
|
||||
lg: 1rem,
|
||||
xl: 1.5rem,
|
||||
2xl: 1.75rem,
|
||||
3xl: 2rem,
|
||||
full: 9999px,
|
||||
);
|
||||
|
||||
// Font Sizes
|
||||
// Font Sizes (legacy map)
|
||||
$font-sizes: (
|
||||
xs: 0.75rem,
|
||||
sm: 0.875rem,
|
||||
@@ -143,11 +464,3 @@ $z-index: (
|
||||
50: 50,
|
||||
auto: auto,
|
||||
);
|
||||
|
||||
// Shadow values - Material Design 3 elevation system
|
||||
$shadow-accent: rgba(99, 150, 180, 0.08); // MD3 elevation tint
|
||||
$shadow-accent-md: rgba(99, 150, 180, 0.12);
|
||||
$shadow-focus: rgba(99, 150, 180, 0.10);
|
||||
|
||||
// Hover colors - MD3 state layers
|
||||
$accent-hover: hsl(195, 50%, 55%);
|
||||
|
||||
@@ -1,92 +1,360 @@
|
||||
@use '../abstracts' as *;
|
||||
|
||||
// Button component styles
|
||||
// These complement the existing Tailwind-based button components
|
||||
// ============================================
|
||||
// Material Design 3 Button Styles
|
||||
// ============================================
|
||||
|
||||
// Base button styles (MD3)
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $size-2;
|
||||
padding: $size-2 $size-4;
|
||||
padding: $size-2-5 $size-6;
|
||||
min-width: 64px;
|
||||
min-height: 40px;
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
border-radius: $radius-full;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
font-size: $font-label-large;
|
||||
line-height: $line-height-label-large;
|
||||
letter-spacing: $letter-spacing-label;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
background-color $duration-short-4 $easing-standard,
|
||||
box-shadow $duration-short-4 $easing-standard,
|
||||
color $duration-short-4 $easing-standard;
|
||||
outline: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
// State layer
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background-color: currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity $duration-short-4 $easing-standard;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: $state-hover-opacity;
|
||||
}
|
||||
|
||||
&:focus-visible::before {
|
||||
opacity: $state-focus-opacity;
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
opacity: $state-pressed-opacity;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 3px $secondary, 0 0 0 6px $ring;
|
||||
outline: 2px solid $ring;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: $state-disabled-opacity;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Primary variant - MD3 filled button
|
||||
&-primary {
|
||||
background: $primary;
|
||||
color: $primary-foreground;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); // MD3 elevation-1
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MD3 Filled Button (Primary)
|
||||
// ============================================
|
||||
&-primary,
|
||||
&-filled {
|
||||
background-color: $primary;
|
||||
color: $on-primary;
|
||||
box-shadow: $elevation-1;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14); // MD3 elevation-2
|
||||
background: hsl(260, 45%, 58%); // Slightly lighter on hover
|
||||
box-shadow: $elevation-2;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
||||
box-shadow: $elevation-1;
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary variant
|
||||
&-secondary {
|
||||
background-color: $secondary;
|
||||
color: $secondary-foreground;
|
||||
border: 1px solid $border;
|
||||
// ============================================
|
||||
// MD3 Filled Tonal Button
|
||||
// ============================================
|
||||
&-tonal {
|
||||
background-color: $secondary-container;
|
||||
color: $on-secondary-container;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: hsl(217.2, 32.6%, 20%);
|
||||
box-shadow: $elevation-1;
|
||||
}
|
||||
}
|
||||
|
||||
// Ghost variant
|
||||
// ============================================
|
||||
// MD3 Elevated Button
|
||||
// ============================================
|
||||
&-elevated {
|
||||
background-color: $surface-container-low;
|
||||
color: $primary;
|
||||
box-shadow: $elevation-1;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: $elevation-2;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
box-shadow: $elevation-1;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MD3 Outlined Button
|
||||
// ============================================
|
||||
&-outline,
|
||||
&-outlined {
|
||||
background-color: transparent;
|
||||
color: $primary;
|
||||
border: 1px solid $outline;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: rgba($primary, $state-hover-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MD3 Text Button
|
||||
// ============================================
|
||||
&-text,
|
||||
&-ghost {
|
||||
background-color: transparent;
|
||||
color: $accent;
|
||||
border: 1px solid $accent;
|
||||
color: $primary;
|
||||
padding-left: $size-3;
|
||||
padding-right: $size-3;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: rgba(99, 242, 255, 0.1);
|
||||
background-color: rgba($primary, $state-hover-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
// Outline variant
|
||||
&-outline {
|
||||
background-color: transparent;
|
||||
color: $foreground;
|
||||
border: 1px solid $border;
|
||||
// ============================================
|
||||
// Destructive/Error Button
|
||||
// ============================================
|
||||
&-destructive,
|
||||
&-error {
|
||||
background-color: $destructive;
|
||||
color: $on-error;
|
||||
box-shadow: $elevation-1;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $secondary;
|
||||
box-shadow: $elevation-2;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-color: $destructive;
|
||||
}
|
||||
}
|
||||
|
||||
// Size variants
|
||||
// ============================================
|
||||
// Secondary Button (maps to tonal)
|
||||
// ============================================
|
||||
&-secondary {
|
||||
background-color: $secondary-container;
|
||||
color: $on-secondary-container;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: $elevation-1;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Size Variants
|
||||
// ============================================
|
||||
&-sm {
|
||||
padding: calc($size-1 * 0.75) $size-2;
|
||||
font-size: 0.75rem;
|
||||
padding: $size-1-5 $size-4;
|
||||
min-height: 32px;
|
||||
font-size: $font-label-medium;
|
||||
gap: $size-1-5;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&-lg {
|
||||
padding: $size-3 $size-6;
|
||||
font-size: 1rem;
|
||||
padding: $size-3 $size-8;
|
||||
min-height: 48px;
|
||||
font-size: $font-label-large;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Icon Button
|
||||
// ============================================
|
||||
&-icon {
|
||||
padding: 0;
|
||||
min-width: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: $radius-full;
|
||||
|
||||
&.btn-sm {
|
||||
min-width: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&.btn-lg {
|
||||
min-width: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MD3 Icon Button (standalone)
|
||||
// ============================================
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: $radius-full;
|
||||
background-color: transparent;
|
||||
color: $on-surface-variant;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: color $duration-short-4 $easing-standard;
|
||||
outline: none;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background-color: currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity $duration-short-4 $easing-standard;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: $state-hover-opacity;
|
||||
}
|
||||
|
||||
&:focus-visible::before {
|
||||
opacity: $state-focus-opacity;
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
opacity: $state-pressed-opacity;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $ring;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: $state-disabled-opacity;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
// Filled variant
|
||||
&-filled {
|
||||
background-color: $primary;
|
||||
color: $on-primary;
|
||||
}
|
||||
|
||||
// Tonal variant
|
||||
&-tonal {
|
||||
background-color: $secondary-container;
|
||||
color: $on-secondary-container;
|
||||
}
|
||||
|
||||
// Outlined variant
|
||||
&-outlined {
|
||||
border: 1px solid $outline;
|
||||
color: $on-surface-variant;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Navigation Button (burger menu)
|
||||
// ============================================
|
||||
.nav-burger-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: $radius-full;
|
||||
background-color: transparent;
|
||||
color: $on-surface;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: color $duration-short-4 $easing-standard;
|
||||
outline: none;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background-color: currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity $duration-short-4 $easing-standard;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: $state-hover-opacity;
|
||||
}
|
||||
|
||||
&:focus-visible::before {
|
||||
opacity: $state-focus-opacity;
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
opacity: $state-pressed-opacity;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $ring;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
@use '../abstracts' as *;
|
||||
|
||||
// Dialog/Modal styling - Material Design 3
|
||||
// ============================================
|
||||
// Material Design 3 Dialog/Modal Styles
|
||||
// ============================================
|
||||
|
||||
// Scrim/Overlay
|
||||
[data-slot="dialog-overlay"],
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background-color: rgba(0, 0, 0, 0.5); // MD3 scrim opacity
|
||||
backdrop-filter: blur(4px); // Subtle blur
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
background-color: rgba($scrim, 0.32); // MD3 scrim opacity
|
||||
animation: md3-fade-in $duration-medium-2 $easing-emphasized-decelerate;
|
||||
}
|
||||
|
||||
// Dialog Content (MD3 Basic Dialog)
|
||||
[data-slot="dialog"],
|
||||
.dialog-content {
|
||||
position: fixed;
|
||||
@@ -18,139 +22,239 @@
|
||||
top: 50%;
|
||||
z-index: 50;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 90%;
|
||||
max-width: 32rem;
|
||||
max-height: 85vh;
|
||||
width: calc(100% - 48px);
|
||||
max-width: 560px;
|
||||
max-height: calc(100vh - 96px);
|
||||
overflow-y: auto;
|
||||
background: $popover; // MD3 surface-container-high
|
||||
border: 1px solid rgba($border, 0.3);
|
||||
border-radius: $radius-xl; // MD3 extra-large shape (28px)
|
||||
box-shadow:
|
||||
0 8px 16px rgba(0, 0, 0, 0.18), // MD3 elevation-3
|
||||
0 4px 8px rgba(0, 0, 0, 0.12);
|
||||
animation: dialogSlideIn 0.25s cubic-bezier(0.2, 0, 0, 1); // MD3 emphasized easing
|
||||
|
||||
background: $surface-container-high;
|
||||
border: none;
|
||||
border-radius: $radius-extra-large;
|
||||
box-shadow: $elevation-3;
|
||||
padding: $size-6;
|
||||
animation: md3-dialog-enter $duration-medium-2 $easing-emphasized-decelerate;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown menu styling - Material Design 3
|
||||
// Dialog Header
|
||||
[data-slot="dialog-header"],
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $size-4;
|
||||
margin-bottom: $size-6;
|
||||
}
|
||||
|
||||
// Dialog Title (MD3 Headline Small)
|
||||
[data-slot="dialog-title"],
|
||||
.dialog-title {
|
||||
font-size: $font-headline-small;
|
||||
line-height: $line-height-headline-small;
|
||||
font-weight: 400;
|
||||
color: $on-surface;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Dialog Description (MD3 Body Medium)
|
||||
[data-slot="dialog-description"],
|
||||
.dialog-description {
|
||||
font-size: $font-body-medium;
|
||||
line-height: $line-height-body-medium;
|
||||
color: $on-surface-variant;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Dialog Footer (Actions)
|
||||
[data-slot="dialog-footer"],
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: $size-2;
|
||||
margin-top: $size-6;
|
||||
}
|
||||
|
||||
// Dialog Close Button
|
||||
[data-slot="dialog-close"],
|
||||
.dialog-close {
|
||||
position: absolute;
|
||||
top: $size-4;
|
||||
right: $size-4;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $radius-full;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: $on-surface-variant;
|
||||
cursor: pointer;
|
||||
transition: background-color $duration-short-4 $easing-standard;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($on-surface, $state-hover-opacity);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background-color: rgba($on-surface, $state-focus-opacity);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MD3 Dropdown Menu
|
||||
// ============================================
|
||||
[data-slot="dropdown-menu-content"],
|
||||
.dropdown-content {
|
||||
z-index: 50;
|
||||
min-width: 12rem;
|
||||
min-width: 112px;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
border-radius: $radius-lg; // MD3 large shape
|
||||
border: 1px solid rgba($border, 0.3);
|
||||
background: $popover;
|
||||
box-shadow:
|
||||
0 4px 8px rgba(0, 0, 0, 0.14), // MD3 elevation-2
|
||||
0 2px 4px rgba(0, 0, 0, 0.10);
|
||||
padding: 0.5rem;
|
||||
animation: dropdownSlideIn 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||
|
||||
border-radius: $radius-extra-small;
|
||||
border: none;
|
||||
background: $surface-container;
|
||||
box-shadow: $elevation-2;
|
||||
padding: $size-2 0;
|
||||
animation: md3-menu-enter $duration-short-4 $easing-emphasized-decelerate;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown Menu Item
|
||||
[data-slot="dropdown-menu-item"],
|
||||
.dropdown-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: $foreground;
|
||||
border-radius: $radius-md; // MD3 medium shape
|
||||
gap: $size-3;
|
||||
min-height: 48px;
|
||||
padding: 0 $size-3;
|
||||
font-size: $font-body-large;
|
||||
line-height: $line-height-body-large;
|
||||
color: $on-surface;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
transition: background-color $duration-short-4 $easing-standard;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($accent, 0.08); // MD3 state layer
|
||||
background-color: rgba($on-surface, $state-hover-opacity);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: rgba($accent, 0.12);
|
||||
background-color: rgba($on-surface, $state-focus-opacity);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: rgba($on-surface, $state-pressed-opacity);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
pointer-events: none;
|
||||
opacity: 0.38; // MD3 disabled opacity
|
||||
opacity: $state-disabled-opacity;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: $on-surface-variant;
|
||||
}
|
||||
}
|
||||
|
||||
// Popover styling - refined and elegant
|
||||
// Dropdown Menu Separator
|
||||
[data-slot="dropdown-menu-separator"],
|
||||
.dropdown-separator {
|
||||
height: 1px;
|
||||
background: $outline-variant;
|
||||
margin: $size-2 0;
|
||||
}
|
||||
|
||||
// Dropdown Menu Label
|
||||
[data-slot="dropdown-menu-label"],
|
||||
.dropdown-label {
|
||||
padding: $size-2 $size-3;
|
||||
font-size: $font-label-large;
|
||||
font-weight: 500;
|
||||
color: $on-surface-variant;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MD3 Popover
|
||||
// ============================================
|
||||
[data-slot="popover-content"],
|
||||
.popover-content {
|
||||
z-index: 50;
|
||||
width: auto;
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid rgba($border, 0.5);
|
||||
background: rgba($popover, 0.95);
|
||||
backdrop-filter: blur(12px) saturate(150%);
|
||||
padding: 1rem;
|
||||
box-shadow:
|
||||
0 10px 25px rgba(0, 0, 0, 0.2),
|
||||
0 4px 10px rgba(0, 0, 0, 0.15);
|
||||
animation: popoverSlideIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
min-width: 112px;
|
||||
border-radius: $radius-medium;
|
||||
border: none;
|
||||
background: $surface-container;
|
||||
box-shadow: $elevation-2;
|
||||
padding: $size-4;
|
||||
animation: md3-menu-enter $duration-short-4 $easing-emphasized-decelerate;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Select dropdown styling - beautiful custom select
|
||||
// ============================================
|
||||
// MD3 Select
|
||||
// ============================================
|
||||
[data-slot="select-trigger"],
|
||||
.select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid rgba($border, 0.5);
|
||||
background: rgba($input, 0.5);
|
||||
color: $foreground;
|
||||
min-height: 56px;
|
||||
padding: $size-4;
|
||||
font-size: $font-body-large;
|
||||
border-radius: $radius-extra-small;
|
||||
border: 1px solid $outline;
|
||||
background: transparent;
|
||||
color: $on-surface;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: border-color $duration-short-4 $easing-standard;
|
||||
|
||||
&:hover {
|
||||
background: rgba($input, 0.7);
|
||||
border-color: rgba($accent, 0.5);
|
||||
border-color: $on-surface;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $ring;
|
||||
box-shadow: 0 0 0 3px rgba($ring, 0.1);
|
||||
border-color: $primary;
|
||||
border-width: 2px;
|
||||
padding: calc(#{$size-4} - 1px);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
opacity: $state-disabled-opacity;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: $on-surface-variant;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="select-content"],
|
||||
.select-content {
|
||||
z-index: 50;
|
||||
min-width: 8rem;
|
||||
min-width: 112px;
|
||||
overflow: hidden;
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid rgba($border, 0.5);
|
||||
background: rgba($popover, 0.95);
|
||||
backdrop-filter: blur(12px) saturate(150%);
|
||||
box-shadow:
|
||||
0 10px 25px rgba(0, 0, 0, 0.2),
|
||||
0 4px 10px rgba(0, 0, 0, 0.15);
|
||||
padding: 0.375rem;
|
||||
animation: selectSlideIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: $radius-extra-small;
|
||||
border: none;
|
||||
background: $surface-container;
|
||||
box-shadow: $elevation-2;
|
||||
padding: $size-2 0;
|
||||
animation: md3-menu-enter $duration-short-4 $easing-emphasized-decelerate;
|
||||
}
|
||||
|
||||
[data-slot="select-item"],
|
||||
@@ -158,45 +262,46 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem 0.5rem 2rem;
|
||||
font-size: 0.875rem;
|
||||
color: $foreground;
|
||||
border-radius: $radius-sm;
|
||||
min-height: 48px;
|
||||
padding: 0 $size-3;
|
||||
font-size: $font-body-large;
|
||||
color: $on-surface;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
transition: background-color $duration-short-4 $easing-standard;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($accent, 0.12);
|
||||
color: $accent;
|
||||
background-color: rgba($on-surface, $state-hover-opacity);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: rgba($accent, 0.15);
|
||||
color: $accent;
|
||||
background-color: rgba($on-surface, $state-focus-opacity);
|
||||
}
|
||||
|
||||
&[data-state="checked"] {
|
||||
background-color: rgba($accent, 0.1);
|
||||
color: $accent;
|
||||
font-weight: 500;
|
||||
background-color: rgba($primary, 0.12);
|
||||
|
||||
&::before {
|
||||
content: '✓';
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
opacity: $state-disabled-opacity;
|
||||
}
|
||||
}
|
||||
|
||||
// Alert Dialog styling - attention-grabbing
|
||||
// ============================================
|
||||
// MD3 Alert Dialog
|
||||
// ============================================
|
||||
[data-slot="alert-dialog-content"],
|
||||
.alert-dialog-content {
|
||||
position: fixed;
|
||||
@@ -204,24 +309,115 @@
|
||||
top: 50%;
|
||||
z-index: 50;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 90%;
|
||||
max-width: 28rem;
|
||||
background: linear-gradient(135deg, rgba($card, 0.95) 0%, rgba($card, 0.98) 100%);
|
||||
border: 1px solid rgba($destructive, 0.3);
|
||||
border-radius: $radius-xl;
|
||||
box-shadow:
|
||||
0 20px 40px rgba($destructive, 0.2),
|
||||
0 10px 20px rgba(0, 0, 0, 0.2);
|
||||
padding: 1.5rem;
|
||||
animation: alertSlideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
width: calc(100% - 48px);
|
||||
max-width: 560px;
|
||||
background: $surface-container-high;
|
||||
border: none;
|
||||
border-radius: $radius-extra-large;
|
||||
box-shadow: $elevation-3;
|
||||
padding: $size-6;
|
||||
animation: md3-dialog-enter $duration-medium-2 $easing-emphasized-decelerate;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Alert Dialog with icon (optional)
|
||||
.alert-dialog-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: $size-4;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: $on-surface-variant;
|
||||
}
|
||||
|
||||
&--error svg {
|
||||
color: $destructive;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MD3 Snackbar/Toast
|
||||
// ============================================
|
||||
.snackbar,
|
||||
[data-slot="toast"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $size-2;
|
||||
min-height: 48px;
|
||||
padding: $size-3-5 $size-4;
|
||||
background: $inverse-surface;
|
||||
color: $inverse-on-surface;
|
||||
border-radius: $radius-extra-small;
|
||||
box-shadow: $elevation-3;
|
||||
font-size: $font-body-medium;
|
||||
animation: md3-snackbar-enter $duration-medium-1 $easing-emphasized-decelerate;
|
||||
}
|
||||
|
||||
.snackbar-action {
|
||||
margin-left: auto;
|
||||
padding: 0 $size-2;
|
||||
font-size: $font-label-large;
|
||||
font-weight: 500;
|
||||
color: $inverse-primary;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity $duration-short-4 $easing-standard;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Namespace Selector (MD3 styled)
|
||||
// ============================================
|
||||
.namespace-select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $size-2;
|
||||
padding: $size-2 $size-4;
|
||||
font-size: $font-label-large;
|
||||
font-weight: 500;
|
||||
border-radius: $radius-small;
|
||||
border: 1px solid $outline-variant;
|
||||
background: $surface-container;
|
||||
color: $on-surface;
|
||||
cursor: pointer;
|
||||
transition: all $duration-short-4 $easing-standard;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: $on-surface-variant;
|
||||
transition: color $duration-short-4 $easing-standard;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $surface-container-high;
|
||||
border-color: $outline;
|
||||
|
||||
svg {
|
||||
color: $on-surface;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
box-shadow: 0 0 0 2px rgba($primary, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Animations
|
||||
@keyframes fadeIn {
|
||||
// ============================================
|
||||
@keyframes md3-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -230,10 +426,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialogSlideIn {
|
||||
@keyframes md3-dialog-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
transform: translate(-50%, -48%) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -241,21 +437,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdownSlideIn {
|
||||
@keyframes md3-menu-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes popoverSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -263,64 +448,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes selectSlideIn {
|
||||
@keyframes md3-snackbar-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes alertSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -45%) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace selector specific styling
|
||||
.namespace-select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: $radius-lg;
|
||||
border: 1px solid rgba($border, 0.5);
|
||||
background: rgba($secondary, 0.5);
|
||||
color: $foreground;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: $muted-foreground;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba($secondary, 0.7);
|
||||
border-color: rgba($accent, 0.4);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba($accent, 0.1);
|
||||
|
||||
svg {
|
||||
color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $ring;
|
||||
box-shadow: 0 0 0 3px rgba($ring, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user