mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
Added sprint protocol
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
.PresetCard {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--theme-color-primary, #3b82f6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: var(--theme-color-primary, #3b82f6);
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
|
||||
&:hover {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.PresetCard-mockup {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.PresetCard-btn {
|
||||
height: 10px;
|
||||
width: 60%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.PresetCard-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.PresetCard-line {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.PresetCard-surface {
|
||||
flex: 1;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.PresetCard-name {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-fg-default, #e4e4e7);
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.PresetCard-check {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--theme-color-primary, #3b82f6);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 2px;
|
||||
width: 5px;
|
||||
height: 8px;
|
||||
border: 2px solid #fff;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* STYLE-003: PresetCard
|
||||
*
|
||||
* Individual card showing a visual mini-preview of a style preset.
|
||||
* Renders a small mockup using the preset's preview colors/radius
|
||||
* so users can see what their UI will look like before choosing.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import css from './PresetCard.module.scss';
|
||||
|
||||
export interface PresetDisplayInfo {
|
||||
/** Unique slug id (e.g. 'modern', 'minimal'). */
|
||||
id: string;
|
||||
/** Human-readable name. */
|
||||
name: string;
|
||||
/** Short description shown below the card grid. */
|
||||
description: string;
|
||||
/** Raw CSS values for the visual preview. No token references. */
|
||||
preview: {
|
||||
primaryColor: string;
|
||||
backgroundColor: string;
|
||||
surfaceColor: string;
|
||||
borderColor: string;
|
||||
textColor: string;
|
||||
mutedTextColor: string;
|
||||
radiusMd: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PresetCardProps {
|
||||
preset: PresetDisplayInfo;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export function PresetCard({ preset, isSelected, onSelect }: PresetCardProps) {
|
||||
const { preview } = preset;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[css['PresetCard'], isSelected ? css['PresetCard--selected'] : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => onSelect(preset.id)}
|
||||
aria-pressed={isSelected}
|
||||
title={preset.name}
|
||||
>
|
||||
{/* Mini UI mockup */}
|
||||
<div
|
||||
className={css['PresetCard-mockup']}
|
||||
style={{ backgroundColor: preview.backgroundColor, borderColor: preview.borderColor }}
|
||||
>
|
||||
{/* Primary button row */}
|
||||
<div
|
||||
className={css['PresetCard-btn']}
|
||||
style={{
|
||||
backgroundColor: preview.primaryColor,
|
||||
borderRadius: preview.radiusMd
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Text lines */}
|
||||
<div className={css['PresetCard-lines']}>
|
||||
<div className={css['PresetCard-line']} style={{ backgroundColor: preview.textColor, width: '70%' }} />
|
||||
<div className={css['PresetCard-line']} style={{ backgroundColor: preview.mutedTextColor, width: '50%' }} />
|
||||
</div>
|
||||
|
||||
{/* Surface card strip */}
|
||||
<div
|
||||
className={css['PresetCard-surface']}
|
||||
style={{
|
||||
backgroundColor: preview.surfaceColor,
|
||||
borderColor: preview.borderColor,
|
||||
borderRadius: `calc(${preview.radiusMd} * 0.6)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preset name */}
|
||||
<span className={css['PresetCard-name']}>{preset.name}</span>
|
||||
|
||||
{/* Selected indicator */}
|
||||
{isSelected && <span className={css['PresetCard-check']} aria-hidden />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.PresetSelector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.PresetSelector-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-default, #e4e4e7);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.PresetSelector-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.PresetSelector-description {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default-shy, #71717a);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
min-height: 1.4em; /* reserve height to prevent layout shift */
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* STYLE-003: PresetSelector
|
||||
*
|
||||
* Horizontal grid of PresetCards with a description below for the
|
||||
* currently-selected preset.
|
||||
*
|
||||
* Usage:
|
||||
* <PresetSelector
|
||||
* presets={getAllPresets()}
|
||||
* selectedId="modern"
|
||||
* onChange={(id) => setPreset(id)}
|
||||
* />
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { PresetCard, PresetDisplayInfo } from './PresetCard';
|
||||
import css from './PresetSelector.module.scss';
|
||||
|
||||
export interface PresetSelectorProps {
|
||||
/** All available presets to display. */
|
||||
presets: PresetDisplayInfo[];
|
||||
/** Currently selected preset id. */
|
||||
selectedId: string;
|
||||
/** Called when the user picks a different preset. */
|
||||
onChange: (id: string) => void;
|
||||
}
|
||||
|
||||
export function PresetSelector({ presets, selectedId, onChange }: PresetSelectorProps) {
|
||||
const selected = presets.find((p) => p.id === selectedId);
|
||||
|
||||
return (
|
||||
<div className={css['PresetSelector']}>
|
||||
<span className={css['PresetSelector-label']}>Style Preset</span>
|
||||
|
||||
<div className={css['PresetSelector-grid']} role="group" aria-label="Style presets">
|
||||
{presets.map((preset) => (
|
||||
<PresetCard key={preset.id} preset={preset} isSelected={preset.id === selectedId} onSelect={onChange} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && <p className={css['PresetSelector-description']}>{selected.description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { PresetCard } from './PresetCard';
|
||||
export type { PresetDisplayInfo, PresetCardProps } from './PresetCard';
|
||||
export { PresetSelector } from './PresetSelector';
|
||||
export type { PresetSelectorProps } from './PresetSelector';
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* STYLE-002: VariantSelector styles
|
||||
* All colors use design tokens — no hardcoded values.
|
||||
*/
|
||||
|
||||
.VariantSelector {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1, 4px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Label above the trigger
|
||||
.VariantSelector-label {
|
||||
font-size: var(--font-size-xsmall, 11px);
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// Dropdown trigger button
|
||||
.VariantSelector-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-1, 4px);
|
||||
width: 100%;
|
||||
padding: var(--spacing-1, 4px) var(--spacing-2, 8px);
|
||||
background: var(--theme-color-bg-3);
|
||||
color: var(--theme-color-fg-default);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
font-size: var(--font-size-small, 12px);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 100ms ease, background 100ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--theme-color-bg-2);
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--theme-color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.VariantSelector-triggerText {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.VariantSelector-chevron {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Dropdown list
|
||||
.VariantSelector-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--spacing-1, 4px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
background: var(--theme-color-bg-2);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
// Individual option
|
||||
.VariantSelector-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2, 8px);
|
||||
padding: var(--spacing-1, 4px) var(--spacing-2, 8px);
|
||||
background: transparent;
|
||||
color: var(--theme-color-fg-default);
|
||||
border: none;
|
||||
font-size: var(--font-size-small, 12px);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: background 80ms ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--theme-color-primary);
|
||||
background: color-mix(in srgb, var(--theme-color-primary) 10%, transparent);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--theme-color-primary) 15%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.VariantSelector-optionLabel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.VariantSelector-checkmark {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--theme-color-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* STYLE-002: VariantSelector
|
||||
*
|
||||
* Dropdown component for selecting a node style variant.
|
||||
* Displays the current variant name and opens a list of available variants.
|
||||
*
|
||||
* Usage:
|
||||
* <VariantSelector
|
||||
* variants={['primary', 'secondary', 'outline', 'ghost']}
|
||||
* currentVariant="primary"
|
||||
* onVariantChange={(name) => applyVariant(node, nodeType, name)}
|
||||
* />
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import css from './VariantSelector.module.scss';
|
||||
|
||||
export interface VariantSelectorProps {
|
||||
/** List of available variant names to display. */
|
||||
variants: string[];
|
||||
|
||||
/** Currently active variant name. */
|
||||
currentVariant: string | undefined;
|
||||
|
||||
/** Called when the user picks a different variant. */
|
||||
onVariantChange: (variantName: string) => void;
|
||||
|
||||
/** Disable the selector (read-only). */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Optional label shown above the selector. Defaults to 'Variant'. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a variant name for display: 'heading-1' → 'Heading 1', 'flex-row' → 'Flex Row'.
|
||||
*/
|
||||
function formatVariantLabel(name: string): string {
|
||||
return name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function VariantSelector({
|
||||
variants,
|
||||
currentVariant,
|
||||
onVariantChange,
|
||||
disabled = false,
|
||||
label = 'Variant'
|
||||
}: VariantSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!disabled) setIsOpen((v) => !v);
|
||||
}, [disabled]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(name: string) => {
|
||||
setIsOpen(false);
|
||||
if (name !== currentVariant) {
|
||||
onVariantChange(name);
|
||||
}
|
||||
},
|
||||
[currentVariant, onVariantChange]
|
||||
);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown);
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setIsOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className={css['VariantSelector']} ref={containerRef}>
|
||||
<span className={css['VariantSelector-label']}>{label}</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={css['VariantSelector-trigger']}
|
||||
onClick={toggle}
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
title={currentVariant ? formatVariantLabel(currentVariant) : 'No variant'}
|
||||
>
|
||||
<span className={css['VariantSelector-triggerText']}>
|
||||
{currentVariant ? formatVariantLabel(currentVariant) : 'None'}
|
||||
</span>
|
||||
<span className={css['VariantSelector-chevron']} aria-hidden>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className={css['VariantSelector-dropdown']} role="listbox">
|
||||
{variants.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={name === currentVariant}
|
||||
className={[
|
||||
css['VariantSelector-option'],
|
||||
name === currentVariant ? css['VariantSelector-option--active'] : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => handleSelect(name)}
|
||||
>
|
||||
<span className={css['VariantSelector-optionLabel']}>{formatVariantLabel(name)}</span>
|
||||
{name === currentVariant && (
|
||||
<span className={css['VariantSelector-checkmark']} aria-hidden>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { VariantSelector } from './VariantSelector';
|
||||
export type { VariantSelectorProps } from './VariantSelector';
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* STYLE-004: ElementStyleSection styles
|
||||
* Matches the visual rhythm of the existing property editor sections.
|
||||
* All colors via design tokens — no hardcoded values.
|
||||
*/
|
||||
|
||||
.ElementStyleSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ElementStyleSection-header {
|
||||
padding: var(--spacing-1, 4px) var(--spacing-2, 8px);
|
||||
font-size: var(--font-size-xsmall, 11px);
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
user-select: none;
|
||||
background: var(--theme-color-bg-2);
|
||||
}
|
||||
|
||||
.ElementStyleSection-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2, 8px);
|
||||
padding: var(--spacing-2, 8px);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* STYLE-004: ElementStyleSection
|
||||
*
|
||||
* Property panel section that surfaces the ElementConfig variant and size system.
|
||||
* Rendered at the top of the property panel for any node that has an ElementConfig
|
||||
* registered (Button, Text, Group, TextInput, Checkbox).
|
||||
*
|
||||
* The component is intentionally dumb — it receives callbacks for variant and size
|
||||
* changes so that the legacy propertyeditor.ts can manage undo grouping via
|
||||
* UndoActionGroup + UndoQueue without this component needing to import them.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { VariantSelector } from '../../inputs/VariantSelector';
|
||||
import { SizePicker } from '../SizePicker';
|
||||
import css from './ElementStyleSection.module.scss';
|
||||
|
||||
export interface ElementStyleSectionProps {
|
||||
/** Available variant names from the ElementConfig (e.g. ['primary', 'secondary', 'outline']). */
|
||||
variants: string[];
|
||||
|
||||
/** Currently active variant name (from node.parameters._variant). */
|
||||
currentVariant: string | undefined;
|
||||
|
||||
/** Called when the user selects a different variant. Parent handles undo. */
|
||||
onVariantChange: (variantName: string) => void;
|
||||
|
||||
/**
|
||||
* Available size names from the ElementConfig sizes map (e.g. ['sm', 'md', 'lg', 'xl']).
|
||||
* Pass an empty array or omit to hide the size picker.
|
||||
*/
|
||||
sizes?: string[];
|
||||
|
||||
/** Currently active size name (from node.parameters._size). */
|
||||
currentSize?: string | undefined;
|
||||
|
||||
/** Called when the user selects a different size. Parent handles undo. */
|
||||
onSizeChange?: (sizeName: string) => void;
|
||||
}
|
||||
|
||||
export function ElementStyleSection({
|
||||
variants,
|
||||
currentVariant,
|
||||
onVariantChange,
|
||||
sizes = [],
|
||||
currentSize,
|
||||
onSizeChange
|
||||
}: ElementStyleSectionProps) {
|
||||
const hasSizes = sizes.length > 0 && onSizeChange !== undefined;
|
||||
|
||||
return (
|
||||
<div className={css['ElementStyleSection']}>
|
||||
<div className={css['ElementStyleSection-header']}>Style</div>
|
||||
|
||||
<div className={css['ElementStyleSection-body']}>
|
||||
{variants.length > 0 && (
|
||||
<VariantSelector
|
||||
variants={variants}
|
||||
currentVariant={currentVariant}
|
||||
onVariantChange={onVariantChange}
|
||||
label="Variant"
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasSizes && <SizePicker sizes={sizes} currentSize={currentSize} onSizeChange={onSizeChange} label="Size" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ElementStyleSection } from './ElementStyleSection';
|
||||
export type { ElementStyleSectionProps } from './ElementStyleSection';
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* STYLE-004: SizePicker styles
|
||||
* All colors via design tokens.
|
||||
*/
|
||||
|
||||
.SizePicker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1, 4px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.SizePicker-label {
|
||||
font-size: var(--font-size-xsmall, 11px);
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// Segmented control group
|
||||
.SizePicker-group {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: var(--border-radius-small, 4px);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.SizePicker-option {
|
||||
flex: 1;
|
||||
padding: var(--spacing-1, 4px) 0;
|
||||
background: transparent;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
font-size: var(--font-size-xsmall, 11px);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
transition: background 80ms ease, color 80ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--theme-color-bg-2);
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--theme-color-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--theme-color-bg-2);
|
||||
color: var(--theme-color-fg-highlight);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-color-bg-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* STYLE-004: SizePicker
|
||||
*
|
||||
* Segmented control for selecting an element size preset (sm / md / lg / xl).
|
||||
* Only renders the sizes that are defined in the node's ElementConfig.
|
||||
*
|
||||
* Usage:
|
||||
* <SizePicker
|
||||
* sizes={['sm', 'md', 'lg', 'xl']}
|
||||
* currentSize="md"
|
||||
* onSizeChange={(size) => applySize(node, nodeType, size)}
|
||||
* />
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import css from './SizePicker.module.scss';
|
||||
|
||||
export interface SizePickerProps {
|
||||
/** Available size names in order (e.g. ['sm', 'md', 'lg', 'xl']). */
|
||||
sizes: string[];
|
||||
|
||||
/** Currently active size name. */
|
||||
currentSize: string | undefined;
|
||||
|
||||
/** Called when the user picks a different size. */
|
||||
onSizeChange: (sizeName: string) => void;
|
||||
|
||||
/** Disable all size buttons. */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Optional label. Defaults to 'Size'. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function SizePicker({ sizes, currentSize, onSizeChange, disabled = false, label = 'Size' }: SizePickerProps) {
|
||||
const handleClick = useCallback(
|
||||
(size: string) => {
|
||||
if (!disabled && size !== currentSize) {
|
||||
onSizeChange(size);
|
||||
}
|
||||
},
|
||||
[currentSize, disabled, onSizeChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={css['SizePicker']}>
|
||||
<span className={css['SizePicker-label']}>{label}</span>
|
||||
<div className={css['SizePicker-group']} role="group" aria-label={label}>
|
||||
{sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
className={[css['SizePicker-option'], size === currentSize ? css['SizePicker-option--active'] : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => handleClick(size)}
|
||||
disabled={disabled}
|
||||
aria-pressed={size === currentSize}
|
||||
title={size.toUpperCase()}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SizePicker } from './SizePicker';
|
||||
export type { SizePickerProps } from './SizePicker';
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { PrimaryButton, PrimaryButtonVariant, PrimaryButtonSize } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
|
||||
import { PresetDisplayInfo, PresetSelector } from '@noodl-core-ui/components/StylePresets';
|
||||
import { Label } from '@noodl-core-ui/components/typography/Label';
|
||||
|
||||
import css from './CreateProjectModal.module.scss';
|
||||
@@ -9,20 +10,42 @@ import css from './CreateProjectModal.module.scss';
|
||||
export interface CreateProjectModalProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (name: string, location: string) => void;
|
||||
onChooseLocation?: () => Promise<string | null>; // For folder picker
|
||||
/**
|
||||
* Called when the user confirms project creation.
|
||||
* @param name Project name entered by the user.
|
||||
* @param location Directory path chosen by the user.
|
||||
* @param presetId Id of the selected style preset (e.g. 'modern', 'minimal').
|
||||
*/
|
||||
onConfirm: (name: string, location: string, presetId: string) => void;
|
||||
onChooseLocation?: () => Promise<string | null>;
|
||||
/**
|
||||
* Optional list of style presets to show in the modal.
|
||||
* When provided a PresetSelector is rendered; when omitted the selector is hidden.
|
||||
* Pass the result of `getAllPresets()` from noodl-editor's StylePresetsModel.
|
||||
*/
|
||||
presets?: PresetDisplayInfo[];
|
||||
}
|
||||
|
||||
export function CreateProjectModal({ isVisible, onClose, onConfirm, onChooseLocation }: CreateProjectModalProps) {
|
||||
const DEFAULT_PRESET_ID = 'modern';
|
||||
|
||||
export function CreateProjectModal({
|
||||
isVisible,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onChooseLocation,
|
||||
presets
|
||||
}: CreateProjectModalProps) {
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
const [isChoosingLocation, setIsChoosingLocation] = useState(false);
|
||||
const [selectedPresetId, setSelectedPresetId] = useState(DEFAULT_PRESET_ID);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setProjectName('');
|
||||
setLocation('');
|
||||
setSelectedPresetId(DEFAULT_PRESET_ID);
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
@@ -42,7 +65,7 @@ export function CreateProjectModal({ isVisible, onClose, onConfirm, onChooseLoca
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!projectName.trim() || !location) return;
|
||||
onConfirm(projectName.trim(), location);
|
||||
onConfirm(projectName.trim(), location, selectedPresetId);
|
||||
};
|
||||
|
||||
const isValid = projectName.trim().length > 0 && location.length > 0;
|
||||
@@ -91,6 +114,13 @@ export function CreateProjectModal({ isVisible, onClose, onConfirm, onChooseLoca
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Style Preset Selector */}
|
||||
{presets && presets.length > 0 && (
|
||||
<div className={css['Field']}>
|
||||
<PresetSelector presets={presets} selectedId={selectedPresetId} onChange={setSelectedPresetId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview full path */}
|
||||
{projectName && location && (
|
||||
<div className={css['PathPreview']}>
|
||||
|
||||
Reference in New Issue
Block a user