Textarea component and Project form updates
This commit is contained in:
parent
b3c9529e3f
commit
e43fd6335f
10 changed files with 660 additions and 241 deletions
|
|
@ -24,11 +24,22 @@ $unit-16x: $unit * 16;
|
||||||
$unit-18x: $unit * 18;
|
$unit-18x: $unit * 18;
|
||||||
$unit-20x: $unit * 20;
|
$unit-20x: $unit * 20;
|
||||||
|
|
||||||
|
/* Corner Radius
|
||||||
|
* -------------------------------------------------------------------------- */
|
||||||
|
$corner-radius-xs: 4px; // $unit-half
|
||||||
|
$corner-radius-sm: 6px; // $unit-three-fourth
|
||||||
|
$corner-radius-md: 8px; // $unit
|
||||||
|
$corner-radius-lg: 10px; // $unit + 2px
|
||||||
|
$corner-radius-xl: 12px; // $unit * 1.5
|
||||||
|
$corner-radius-2xl: 16px; // $unit-2x
|
||||||
|
$corner-radius-3xl: 24px; // $unit-3x
|
||||||
|
$corner-radius-full: 999px; // Full rounded
|
||||||
|
|
||||||
/* Page properties
|
/* Page properties
|
||||||
* -------------------------------------------------------------------------- */
|
* -------------------------------------------------------------------------- */
|
||||||
$page-corner-radius: $unit;
|
$page-corner-radius: $corner-radius-md;
|
||||||
$image-corner-radius: $unit-2x;
|
$image-corner-radius: $corner-radius-2xl;
|
||||||
$card-corner-radius: $unit-3x;
|
$card-corner-radius: $corner-radius-3xl;
|
||||||
|
|
||||||
$page-top-margin: $unit-6x;
|
$page-top-margin: $unit-6x;
|
||||||
|
|
||||||
|
|
@ -109,6 +120,7 @@ $salmon-pink: #ffd5cf; // Desaturated salmon pink for hover states
|
||||||
$bg-color: #e8e8e8;
|
$bg-color: #e8e8e8;
|
||||||
$page-color: #ffffff;
|
$page-color: #ffffff;
|
||||||
$card-color: #f7f7f7;
|
$card-color: #f7f7f7;
|
||||||
|
$card-color-hover: #f0f0f0;
|
||||||
|
|
||||||
$text-color-light: #b2b2b2;
|
$text-color-light: #b2b2b2;
|
||||||
$text-color-body: #666666;
|
$text-color-body: #666666;
|
||||||
|
|
@ -149,6 +161,14 @@ $twitter-text-color: #0f5f9b;
|
||||||
$corner-radius: $unit-2x;
|
$corner-radius: $unit-2x;
|
||||||
$mobile-corner-radius: $unit-2x;
|
$mobile-corner-radius: $unit-2x;
|
||||||
|
|
||||||
|
/* Inputs
|
||||||
|
* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
$input-background-color: #f7f7f7;
|
||||||
|
$input-background-color-hover: #f0f0f0;
|
||||||
|
$input-text-color: #666666;
|
||||||
|
$input-text-color-hover: #4d4d4d;
|
||||||
|
|
||||||
/* Avatar header
|
/* Avatar header
|
||||||
* -------------------------------------------------------------------------- */
|
* -------------------------------------------------------------------------- */
|
||||||
$avatar-radius: 2rem;
|
$avatar-radius: 2rem;
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,7 @@
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<Input
|
<Input
|
||||||
label="Album Title"
|
label="Album Title"
|
||||||
|
size="jumbo"
|
||||||
bind:value={title}
|
bind:value={title}
|
||||||
placeholder="Enter album title"
|
placeholder="Enter album title"
|
||||||
required={true}
|
required={true}
|
||||||
|
|
|
||||||
|
|
@ -261,7 +261,7 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<Input label="Title" bind:value={title} required placeholder="Essay title" />
|
<Input label="Title" size="jumbo" bind:value={title} required placeholder="Essay title" />
|
||||||
|
|
||||||
<Input label="Slug" bind:value={slug} placeholder="essay-url-slug" />
|
<Input label="Slug" bind:value={slug} placeholder="essay-url-slug" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLInputAttributes, HTMLTextareaAttributes } from 'svelte/elements'
|
import type { HTMLInputAttributes } from 'svelte/elements'
|
||||||
|
|
||||||
// Type helpers for different input elements
|
type Props = HTMLInputAttributes & {
|
||||||
type InputProps = HTMLInputAttributes & {
|
|
||||||
type?:
|
type?:
|
||||||
| 'text'
|
| 'text'
|
||||||
| 'email'
|
| 'email'
|
||||||
|
|
@ -14,19 +13,10 @@
|
||||||
| 'date'
|
| 'date'
|
||||||
| 'time'
|
| 'time'
|
||||||
| 'color'
|
| 'color'
|
||||||
}
|
|
||||||
|
|
||||||
type TextareaProps = HTMLTextareaAttributes & {
|
|
||||||
type: 'textarea'
|
|
||||||
rows?: number
|
|
||||||
autoResize?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = (InputProps | TextareaProps) & {
|
|
||||||
label?: string
|
label?: string
|
||||||
error?: string
|
error?: string
|
||||||
helpText?: string
|
helpText?: string
|
||||||
size?: 'small' | 'medium' | 'large'
|
size?: 'small' | 'medium' | 'large' | 'jumbo'
|
||||||
pill?: boolean
|
pill?: boolean
|
||||||
fullWidth?: boolean
|
fullWidth?: boolean
|
||||||
required?: boolean
|
required?: boolean
|
||||||
|
|
@ -64,8 +54,6 @@
|
||||||
...restProps
|
...restProps
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
// For textarea auto-resize
|
|
||||||
let textareaElement: HTMLTextAreaElement | undefined = $state()
|
|
||||||
let charCount = $derived(String(value).length)
|
let charCount = $derived(String(value).length)
|
||||||
let charsRemaining = $derived(maxLength ? maxLength - charCount : 0)
|
let charsRemaining = $derived(maxLength ? maxLength - charCount : 0)
|
||||||
|
|
||||||
|
|
@ -92,16 +80,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-resize textarea
|
|
||||||
$effect(() => {
|
|
||||||
if (type === 'textarea' && textareaElement && isTextarea(restProps) && restProps.autoResize) {
|
|
||||||
// Reset height to auto to get the correct scrollHeight
|
|
||||||
textareaElement.style.height = 'auto'
|
|
||||||
// Set the height to match content
|
|
||||||
textareaElement.style.height = textareaElement.scrollHeight + 'px'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Compute classes
|
// Compute classes
|
||||||
const wrapperClasses = $derived(() => {
|
const wrapperClasses = $derived(() => {
|
||||||
const classes = ['input-wrapper']
|
const classes = ['input-wrapper']
|
||||||
|
|
@ -112,8 +90,6 @@
|
||||||
if (prefixIcon) classes.push('has-prefix-icon')
|
if (prefixIcon) classes.push('has-prefix-icon')
|
||||||
if (suffixIcon) classes.push('has-suffix-icon')
|
if (suffixIcon) classes.push('has-suffix-icon')
|
||||||
if (colorSwatch) classes.push('has-color-swatch')
|
if (colorSwatch) classes.push('has-color-swatch')
|
||||||
if (type === 'textarea' && isTextarea(restProps) && restProps.autoResize)
|
|
||||||
classes.push('has-auto-resize')
|
|
||||||
if (wrapperClass) classes.push(wrapperClass)
|
if (wrapperClass) classes.push(wrapperClass)
|
||||||
if (className) classes.push(className)
|
if (className) classes.push(className)
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
|
|
@ -126,11 +102,6 @@
|
||||||
if (inputClass) classes.push(inputClass)
|
if (inputClass) classes.push(inputClass)
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Type guard for textarea props
|
|
||||||
function isTextarea(props: Props): props is TextareaProps {
|
|
||||||
return props.type === 'textarea'
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={wrapperClasses()}>
|
<div class={wrapperClasses()}>
|
||||||
|
|
@ -161,32 +132,17 @@
|
||||||
></span>
|
></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if type === 'textarea' && isTextarea(restProps)}
|
<input
|
||||||
<textarea
|
bind:value
|
||||||
bind:this={textareaElement}
|
{id}
|
||||||
bind:value
|
{type}
|
||||||
{id}
|
{disabled}
|
||||||
{disabled}
|
{readonly}
|
||||||
{readonly}
|
{required}
|
||||||
{required}
|
{maxLength}
|
||||||
{maxLength}
|
class={inputClasses()}
|
||||||
class={inputClasses()}
|
{...restProps}
|
||||||
rows={restProps.rows || 3}
|
/>
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<input
|
|
||||||
bind:value
|
|
||||||
{id}
|
|
||||||
{type}
|
|
||||||
{disabled}
|
|
||||||
{readonly}
|
|
||||||
{required}
|
|
||||||
{maxLength}
|
|
||||||
class={inputClasses()}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if suffixIcon}
|
{#if suffixIcon}
|
||||||
<span class="input-icon suffix-icon">
|
<span class="input-icon suffix-icon">
|
||||||
|
|
@ -303,23 +259,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input and textarea styles
|
// Input styles
|
||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 14px;
|
border: 1px solid transparent;
|
||||||
border: 1px solid $grey-80;
|
color: $input-text-color;
|
||||||
border-radius: 6px;
|
background-color: $input-background-color;
|
||||||
background-color: white;
|
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $input-background-color-hover;
|
||||||
|
color: $input-text-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: $grey-50;
|
color: $grey-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: $primary-color;
|
background-color: $input-background-color-hover;
|
||||||
background-color: white;
|
color: $input-text-color-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|
@ -337,17 +297,23 @@
|
||||||
// Size variations
|
// Size variations
|
||||||
.input-small {
|
.input-small {
|
||||||
padding: $unit calc($unit * 1.5);
|
padding: $unit calc($unit * 1.5);
|
||||||
font-size: 13px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-medium {
|
.input-medium {
|
||||||
padding: calc($unit * 1.5) $unit-2x;
|
padding: calc($unit * 1.5) $unit-2x;
|
||||||
font-size: 14px;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-large {
|
.input-large {
|
||||||
padding: $unit-2x $unit-3x;
|
padding: $unit-2x $unit-3x;
|
||||||
font-size: 16px;
|
font-size: 1.25rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-jumbo {
|
||||||
|
padding: $unit-2x $unit-2x;
|
||||||
|
font-size: 1.33rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,17 +328,23 @@
|
||||||
&.input-large {
|
&.input-large {
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
}
|
}
|
||||||
|
&.input-jumbo {
|
||||||
|
border-radius: 32px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:not(.input-pill) {
|
.input:not(.input-pill) {
|
||||||
&.input-small {
|
&.input-small {
|
||||||
border-radius: 6px;
|
border-radius: $corner-radius-lg;
|
||||||
}
|
}
|
||||||
&.input-medium {
|
&.input-medium {
|
||||||
border-radius: 8px;
|
border-radius: $corner-radius-2xl;
|
||||||
}
|
}
|
||||||
&.input-large {
|
&.input-large {
|
||||||
border-radius: 10px;
|
border-radius: $corner-radius-2xl;
|
||||||
|
}
|
||||||
|
&.input-jumbo {
|
||||||
|
border-radius: $corner-radius-2xl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -409,31 +381,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Textarea specific
|
|
||||||
textarea.input {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 80px;
|
|
||||||
padding-top: calc($unit * 1.5);
|
|
||||||
padding-bottom: calc($unit * 1.5);
|
|
||||||
line-height: 1.5;
|
|
||||||
overflow-y: hidden; // Important for auto-resize
|
|
||||||
|
|
||||||
&.input-small {
|
|
||||||
min-height: 60px;
|
|
||||||
padding-top: $unit;
|
|
||||||
padding-bottom: $unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.input-large {
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-resizing textarea
|
|
||||||
.has-auto-resize textarea.input {
|
|
||||||
resize: none; // Disable manual resize when auto-resize is enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footer styles
|
// Footer styles
|
||||||
.input-footer {
|
.input-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,11 @@
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
formData: ProjectFormData
|
formData: ProjectFormData
|
||||||
|
validationErrors: Record<string, string>
|
||||||
onSave?: () => Promise<void>
|
onSave?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
let { formData = $bindable(), onSave }: Props = $props()
|
let { formData = $bindable(), validationErrors, onSave }: Props = $props()
|
||||||
|
|
||||||
// State for collapsible logo section
|
// State for collapsible logo section
|
||||||
let showLogoSection = $state(!!formData.logoUrl && formData.logoUrl.trim() !== '')
|
let showLogoSection = $state(!!formData.logoUrl && formData.logoUrl.trim() !== '')
|
||||||
|
|
@ -57,7 +58,7 @@
|
||||||
formData.logoUrl = ''
|
formData.logoUrl = ''
|
||||||
logoMedia = null
|
logoMedia = null
|
||||||
showLogoSection = false
|
showLogoSection = false
|
||||||
|
|
||||||
// Auto-save the removal
|
// Auto-save the removal
|
||||||
if (onSave) {
|
if (onSave) {
|
||||||
await onSave()
|
await onSave()
|
||||||
|
|
@ -72,7 +73,7 @@
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
buttonSize="medium"
|
buttonSize="medium"
|
||||||
onclick={() => showLogoSection = true}
|
onclick={() => (showLogoSection = true)}
|
||||||
iconPosition="left"
|
iconPosition="left"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -110,6 +111,30 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.backgroundColor}
|
||||||
|
label="Background Color"
|
||||||
|
helpText="Hex color for project card"
|
||||||
|
error={validationErrors.backgroundColor}
|
||||||
|
placeholder="#FFFFFF"
|
||||||
|
pattern="^#[0-9A-Fa-f]{6}$"
|
||||||
|
colorSwatch={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.highlightColor}
|
||||||
|
label="Highlight Color"
|
||||||
|
helpText="Accent color for the project"
|
||||||
|
error={validationErrors.highlightColor}
|
||||||
|
placeholder="#000000"
|
||||||
|
pattern="^#[0-9A-Fa-f]{6}$"
|
||||||
|
colorSwatch={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
@ -136,7 +161,6 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: $unit-2x;
|
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|
@ -146,4 +170,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $unit-2x;
|
||||||
|
margin-top: $unit-3x;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.input-wrapper) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import Editor from './Editor.svelte'
|
import Editor from './Editor.svelte'
|
||||||
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
||||||
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
||||||
import ProjectStylingForm from './ProjectStylingForm.svelte'
|
import ProjectImagesForm from './ProjectImagesForm.svelte'
|
||||||
import Button from './Button.svelte'
|
import Button from './Button.svelte'
|
||||||
import StatusDropdown from './StatusDropdown.svelte'
|
import StatusDropdown from './StatusDropdown.svelte'
|
||||||
import { projectSchema } from '$lib/schemas/project'
|
import { projectSchema } from '$lib/schemas/project'
|
||||||
|
|
@ -264,8 +264,8 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProjectMetadataForm bind:formData {validationErrors} onSave={handleSave} />
|
<ProjectMetadataForm bind:formData {validationErrors} onSave={handleSave} />
|
||||||
<ProjectBrandingForm bind:formData onSave={handleSave} />
|
<ProjectBrandingForm bind:formData {validationErrors} onSave={handleSave} />
|
||||||
<ProjectStylingForm bind:formData {validationErrors} />
|
<ProjectImagesForm bind:formData {validationErrors} onSave={handleSave} />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
138
src/lib/components/admin/ProjectImagesForm.svelte
Normal file
138
src/lib/components/admin/ProjectImagesForm.svelte
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Input from './Input.svelte'
|
||||||
|
import ImageUploader from './ImageUploader.svelte'
|
||||||
|
import Button from './Button.svelte'
|
||||||
|
import type { ProjectFormData } from '$lib/types/project'
|
||||||
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
formData: ProjectFormData
|
||||||
|
validationErrors: Record<string, string>
|
||||||
|
onSave?: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
let { formData = $bindable(), validationErrors, onSave }: Props = $props()
|
||||||
|
|
||||||
|
// State for collapsible featured image section
|
||||||
|
let showFeaturedImage = $state(
|
||||||
|
!!formData.featuredImage && formData.featuredImage !== '' && formData.featuredImage !== null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Convert featuredImage string to Media object for ImageUploader
|
||||||
|
let featuredImageMedia = $state<Media | null>(null)
|
||||||
|
|
||||||
|
// Initialize media object from existing featuredImage URL
|
||||||
|
$effect(() => {
|
||||||
|
if (
|
||||||
|
formData.featuredImage &&
|
||||||
|
formData.featuredImage !== '' &&
|
||||||
|
formData.featuredImage !== null &&
|
||||||
|
!featuredImageMedia
|
||||||
|
) {
|
||||||
|
// Only create a minimal Media object if we don't already have one
|
||||||
|
featuredImageMedia = {
|
||||||
|
id: -1, // Temporary ID for existing URLs
|
||||||
|
filename: 'featured-image',
|
||||||
|
originalName: 'featured-image',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
size: 0,
|
||||||
|
url: formData.featuredImage,
|
||||||
|
thumbnailUrl: formData.featuredImage,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
altText: null,
|
||||||
|
description: null,
|
||||||
|
usedIn: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync featuredImageMedia changes back to formData
|
||||||
|
$effect(() => {
|
||||||
|
if (!featuredImageMedia && formData.featuredImage) {
|
||||||
|
formData.featuredImage = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleFeaturedImageUpload(media: Media) {
|
||||||
|
formData.featuredImage = media.url
|
||||||
|
featuredImageMedia = media
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFeaturedImageRemove() {
|
||||||
|
formData.featuredImage = ''
|
||||||
|
featuredImageMedia = null
|
||||||
|
showFeaturedImage = false
|
||||||
|
|
||||||
|
// Auto-save the removal
|
||||||
|
if (onSave) {
|
||||||
|
await onSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-header-with-action">
|
||||||
|
<h2>Images</h2>
|
||||||
|
{#if !showFeaturedImage}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
buttonSize="small"
|
||||||
|
onclick={() => (showFeaturedImage = true)}
|
||||||
|
iconPosition="left"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
slot="icon"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||||
|
<polyline points="21 15 16 10 5 21"></polyline>
|
||||||
|
</svg>
|
||||||
|
Add Featured Image
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showFeaturedImage}
|
||||||
|
<ImageUploader
|
||||||
|
label=""
|
||||||
|
bind:value={featuredImageMedia}
|
||||||
|
onUpload={handleFeaturedImageUpload}
|
||||||
|
onRemove={handleFeaturedImageRemove}
|
||||||
|
placeholder="Upload a featured image for this project"
|
||||||
|
showBrowseLibrary={true}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: $unit-6x;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: $grey-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header-with-action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: $unit-3x;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Input from './Input.svelte'
|
import Input from './Input.svelte'
|
||||||
|
import Textarea from './Textarea.svelte'
|
||||||
import SelectField from './SelectField.svelte'
|
import SelectField from './SelectField.svelte'
|
||||||
import ImageUploader from './ImageUploader.svelte'
|
import SegmentedControlField from './SegmentedControlField.svelte'
|
||||||
import Button from './Button.svelte'
|
|
||||||
import type { ProjectFormData } from '$lib/types/project'
|
import type { ProjectFormData } from '$lib/types/project'
|
||||||
import type { Media } from '@prisma/client'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
formData: ProjectFormData
|
formData: ProjectFormData
|
||||||
|
|
@ -14,90 +13,46 @@
|
||||||
|
|
||||||
let { formData = $bindable(), validationErrors, onSave }: Props = $props()
|
let { formData = $bindable(), validationErrors, onSave }: Props = $props()
|
||||||
|
|
||||||
// State for collapsible featured image section
|
|
||||||
let showFeaturedImage = $state(!!formData.featuredImage && formData.featuredImage !== '' && formData.featuredImage !== null)
|
|
||||||
|
|
||||||
// Convert featuredImage string to Media object for ImageUploader
|
|
||||||
let featuredImageMedia = $state<Media | null>(null)
|
|
||||||
|
|
||||||
// Initialize media object from existing featuredImage URL
|
|
||||||
$effect(() => {
|
|
||||||
if (formData.featuredImage && formData.featuredImage !== '' && formData.featuredImage !== null && !featuredImageMedia) {
|
|
||||||
// Only create a minimal Media object if we don't already have one
|
|
||||||
featuredImageMedia = {
|
|
||||||
id: -1, // Temporary ID for existing URLs
|
|
||||||
filename: 'featured-image',
|
|
||||||
originalName: 'featured-image',
|
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
size: 0,
|
|
||||||
url: formData.featuredImage,
|
|
||||||
thumbnailUrl: formData.featuredImage,
|
|
||||||
width: null,
|
|
||||||
height: null,
|
|
||||||
altText: null,
|
|
||||||
description: null,
|
|
||||||
usedIn: [],
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sync featuredImageMedia changes back to formData
|
|
||||||
$effect(() => {
|
|
||||||
if (!featuredImageMedia && formData.featuredImage) {
|
|
||||||
formData.featuredImage = ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleFeaturedImageUpload(media: Media) {
|
|
||||||
formData.featuredImage = media.url
|
|
||||||
featuredImageMedia = media
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFeaturedImageRemove() {
|
|
||||||
formData.featuredImage = ''
|
|
||||||
featuredImageMedia = null
|
|
||||||
showFeaturedImage = false
|
|
||||||
|
|
||||||
// Auto-save the removal
|
|
||||||
if (onSave) {
|
|
||||||
await onSave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<Input
|
<Input
|
||||||
label="Title"
|
label="Title"
|
||||||
required
|
required
|
||||||
|
size="jumbo"
|
||||||
error={validationErrors.title}
|
error={validationErrors.title}
|
||||||
bind:value={formData.title}
|
bind:value={formData.title}
|
||||||
placeholder="Project title"
|
placeholder="Project title"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Textarea
|
||||||
type="textarea"
|
|
||||||
label="Description"
|
label="Description"
|
||||||
|
size="jumbo"
|
||||||
error={validationErrors.description}
|
error={validationErrors.description}
|
||||||
bind:value={formData.description}
|
bind:value={formData.description}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Short description for project cards"
|
placeholder="Short description for project cards"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SelectField
|
<Input
|
||||||
label="Project Type"
|
type="url"
|
||||||
bind:value={formData.projectType}
|
label="External URL"
|
||||||
error={validationErrors.projectType}
|
error={validationErrors.externalUrl}
|
||||||
options={[
|
bind:value={formData.externalUrl}
|
||||||
{ value: 'work', label: 'Work' },
|
placeholder="https://example.com"
|
||||||
{ value: 'labs', label: 'Labs' }
|
|
||||||
]}
|
|
||||||
helpText="Choose whether this project appears in the Work tab or Labs tab"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row three-column">
|
||||||
|
<SegmentedControlField
|
||||||
|
label="Project Type"
|
||||||
|
bind:value={formData.projectType}
|
||||||
|
error={validationErrors.projectType}
|
||||||
|
options={[
|
||||||
|
{ value: 'work', label: 'Work' },
|
||||||
|
{ value: 'labs', label: 'Labs' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
label="Year"
|
label="Year"
|
||||||
|
|
@ -116,52 +71,6 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input
|
|
||||||
type="url"
|
|
||||||
label="External URL"
|
|
||||||
error={validationErrors.externalUrl}
|
|
||||||
bind:value={formData.externalUrl}
|
|
||||||
placeholder="https://example.com"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if !showFeaturedImage}
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
buttonSize="medium"
|
|
||||||
onclick={() => showFeaturedImage = true}
|
|
||||||
iconPosition="left"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
slot="icon"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
|
||||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
|
||||||
<polyline points="21 15 16 10 5 21"></polyline>
|
|
||||||
</svg>
|
|
||||||
Add Featured Image
|
|
||||||
</Button>
|
|
||||||
{:else if showFeaturedImage}
|
|
||||||
<div class="collapsible-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h3>Featured Image</h3>
|
|
||||||
</div>
|
|
||||||
<ImageUploader
|
|
||||||
label=""
|
|
||||||
bind:value={featuredImageMedia}
|
|
||||||
onUpload={handleFeaturedImageUpload}
|
|
||||||
onRemove={handleFeaturedImageRemove}
|
|
||||||
placeholder="Upload a featured image for this project"
|
|
||||||
showBrowseLibrary={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if formData.status === 'password-protected'}
|
{#if formData.status === 'password-protected'}
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
|
|
@ -179,40 +88,32 @@
|
||||||
.form-section {
|
.form-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit-2x;
|
gap: $unit-4x;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: $grey-10;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: $unit-2x;
|
gap: $unit-4x;
|
||||||
padding-bottom: $unit-3x;
|
padding-bottom: $unit-3x;
|
||||||
|
|
||||||
|
&.three-column {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapsible-section {
|
|
||||||
// No border or background needed
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: $unit-2x;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: $grey-20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
108
src/lib/components/admin/SegmentedControlField.svelte
Normal file
108
src/lib/components/admin/SegmentedControlField.svelte
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string
|
||||||
|
options: Option[]
|
||||||
|
value?: string
|
||||||
|
required?: boolean
|
||||||
|
helpText?: string
|
||||||
|
error?: string
|
||||||
|
fullWidth?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
value = $bindable(),
|
||||||
|
required = false,
|
||||||
|
helpText,
|
||||||
|
error,
|
||||||
|
fullWidth = true
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
function handleChange(newValue: string) {
|
||||||
|
value = newValue
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormFieldWrapper {label} {required} {helpText} {error}>
|
||||||
|
{#snippet children()}
|
||||||
|
<div class="segmented-control-wrapper" class:full-width={fullWidth}>
|
||||||
|
<div class="segmented-control">
|
||||||
|
{#each options as option}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="segment"
|
||||||
|
class:active={value === option.value}
|
||||||
|
onclick={() => handleChange(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</FormFieldWrapper>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.segmented-control-wrapper {
|
||||||
|
&.full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control {
|
||||||
|
display: inline-flex;
|
||||||
|
background-color: $input-background-color;
|
||||||
|
border-radius: $corner-radius-full;
|
||||||
|
padding: 3px;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
// Match medium input height: padding (12px * 2) + font line-height (~20px) + padding for container (3px * 2)
|
||||||
|
height: 50px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 $unit-2x;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: $corner-radius-full;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $input-text-color;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover:not(.active) {
|
||||||
|
color: $input-text-color-hover;
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: white;
|
||||||
|
color: $grey-10;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px $primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
266
src/lib/components/admin/Textarea.svelte
Normal file
266
src/lib/components/admin/Textarea.svelte
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLTextareaAttributes } from 'svelte/elements'
|
||||||
|
|
||||||
|
type Props = HTMLTextareaAttributes & {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
helpText?: string
|
||||||
|
size?: 'small' | 'medium' | 'large' | 'jumbo'
|
||||||
|
fullWidth?: boolean
|
||||||
|
required?: boolean
|
||||||
|
wrapperClass?: string
|
||||||
|
textareaClass?: string
|
||||||
|
showCharCount?: boolean
|
||||||
|
maxLength?: number
|
||||||
|
autoResize?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helpText,
|
||||||
|
size = 'medium',
|
||||||
|
fullWidth = true,
|
||||||
|
required = false,
|
||||||
|
wrapperClass = '',
|
||||||
|
textareaClass = '',
|
||||||
|
showCharCount = false,
|
||||||
|
maxLength,
|
||||||
|
autoResize = false,
|
||||||
|
rows = 3,
|
||||||
|
value = $bindable(''),
|
||||||
|
disabled = false,
|
||||||
|
readonly = false,
|
||||||
|
id = `textarea-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
...restProps
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
// Element reference for auto-resize
|
||||||
|
let textareaElement: HTMLTextAreaElement | undefined = $state()
|
||||||
|
|
||||||
|
// Character counting
|
||||||
|
let charCount = $derived(String(value).length)
|
||||||
|
let charsRemaining = $derived(maxLength ? maxLength - charCount : 0)
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
$effect(() => {
|
||||||
|
if (autoResize && textareaElement) {
|
||||||
|
// Reset height to auto to get the correct scrollHeight
|
||||||
|
textareaElement.style.height = 'auto'
|
||||||
|
// Set the height to match content
|
||||||
|
textareaElement.style.height = textareaElement.scrollHeight + 'px'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Compute wrapper classes
|
||||||
|
function getWrapperClasses() {
|
||||||
|
const classes = ['textarea-wrapper']
|
||||||
|
if (fullWidth) classes.push('full-width')
|
||||||
|
if (error) classes.push('has-error')
|
||||||
|
if (disabled) classes.push('disabled')
|
||||||
|
if (wrapperClass) classes.push(wrapperClass)
|
||||||
|
return classes.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute textarea classes
|
||||||
|
function getTextareaClasses() {
|
||||||
|
const sizeClass = `textarea-${size}`
|
||||||
|
const classes = ['textarea', sizeClass]
|
||||||
|
if (textareaClass) classes.push(textareaClass)
|
||||||
|
return classes.join(' ')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={getWrapperClasses()}>
|
||||||
|
{#if label}
|
||||||
|
<label for={id} class="textarea-label">
|
||||||
|
{label}
|
||||||
|
{#if required}
|
||||||
|
<span class="required">*</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="textarea-container">
|
||||||
|
<textarea
|
||||||
|
bind:this={textareaElement}
|
||||||
|
bind:value
|
||||||
|
{id}
|
||||||
|
{disabled}
|
||||||
|
{readonly}
|
||||||
|
{required}
|
||||||
|
{maxLength}
|
||||||
|
{rows}
|
||||||
|
class={getTextareaClasses()}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if (error || helpText || showCharCount) && !disabled}
|
||||||
|
<div class="textarea-footer">
|
||||||
|
{#if error}
|
||||||
|
<span class="textarea-error">{error}</span>
|
||||||
|
{:else if helpText}
|
||||||
|
<span class="textarea-help">{helpText}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showCharCount && maxLength}
|
||||||
|
<span
|
||||||
|
class="char-count"
|
||||||
|
class:warning={charsRemaining < maxLength * 0.1}
|
||||||
|
class:error={charsRemaining < 0}
|
||||||
|
>
|
||||||
|
{charsRemaining}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import '$styles/variables.scss';
|
||||||
|
|
||||||
|
// Wrapper styles
|
||||||
|
.textarea-wrapper {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.full-width {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-error {
|
||||||
|
.textarea {
|
||||||
|
border-color: $red-50;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $red-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label styles
|
||||||
|
.textarea-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $grey-20;
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: $red-50;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textarea styles
|
||||||
|
.textarea {
|
||||||
|
color: $input-text-color;
|
||||||
|
width: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: $corner-radius-sm;
|
||||||
|
background-color: $input-background-color;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
resize: vertical;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $input-background-color-hover;
|
||||||
|
color: $input-text-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $grey-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
background-color: $input-background-color-hover;
|
||||||
|
color: $input-text-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background-color: $grey-95;
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: $grey-40;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:read-only {
|
||||||
|
background-color: $grey-97;
|
||||||
|
cursor: default;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size variations
|
||||||
|
.textarea-small {
|
||||||
|
padding: $unit calc($unit * 1.5);
|
||||||
|
border-radius: $corner-radius-sm;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-medium {
|
||||||
|
padding: calc($unit * 1.5) $unit-2x;
|
||||||
|
border-radius: $corner-radius-md;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-large {
|
||||||
|
padding: $unit-2x $unit-3x;
|
||||||
|
border-radius: $corner-radius-lg;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-jumbo {
|
||||||
|
padding: $unit-2x $unit-2x;
|
||||||
|
border-radius: $corner-radius-2xl;
|
||||||
|
font-size: 1.33rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer styles
|
||||||
|
.textarea-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: $unit-half;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-error {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $red-50;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-help {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $grey-40;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $grey-40;
|
||||||
|
margin-left: $unit;
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
color: $yellow-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: $red-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue