Textarea component and Project form updates

This commit is contained in:
Justin Edmund 2025-06-10 21:45:24 -07:00
parent b3c9529e3f
commit e43fd6335f
10 changed files with 660 additions and 241 deletions

View file

@ -24,11 +24,22 @@ $unit-16x: $unit * 16;
$unit-18x: $unit * 18;
$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-corner-radius: $unit;
$image-corner-radius: $unit-2x;
$card-corner-radius: $unit-3x;
$page-corner-radius: $corner-radius-md;
$image-corner-radius: $corner-radius-2xl;
$card-corner-radius: $corner-radius-3xl;
$page-top-margin: $unit-6x;
@ -109,6 +120,7 @@ $salmon-pink: #ffd5cf; // Desaturated salmon pink for hover states
$bg-color: #e8e8e8;
$page-color: #ffffff;
$card-color: #f7f7f7;
$card-color-hover: #f0f0f0;
$text-color-light: #b2b2b2;
$text-color-body: #666666;
@ -149,6 +161,14 @@ $twitter-text-color: #0f5f9b;
$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-radius: 2rem;

View file

@ -236,6 +236,7 @@
<div class="form-section">
<Input
label="Album Title"
size="jumbo"
bind:value={title}
placeholder="Enter album title"
required={true}

View file

@ -261,7 +261,7 @@
}}
>
<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" />

View file

@ -1,8 +1,7 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLTextareaAttributes } from 'svelte/elements'
import type { HTMLInputAttributes } from 'svelte/elements'
// Type helpers for different input elements
type InputProps = HTMLInputAttributes & {
type Props = HTMLInputAttributes & {
type?:
| 'text'
| 'email'
@ -14,19 +13,10 @@
| 'date'
| 'time'
| 'color'
}
type TextareaProps = HTMLTextareaAttributes & {
type: 'textarea'
rows?: number
autoResize?: boolean
}
type Props = (InputProps | TextareaProps) & {
label?: string
error?: string
helpText?: string
size?: 'small' | 'medium' | 'large'
size?: 'small' | 'medium' | 'large' | 'jumbo'
pill?: boolean
fullWidth?: boolean
required?: boolean
@ -64,8 +54,6 @@
...restProps
}: Props = $props()
// For textarea auto-resize
let textareaElement: HTMLTextAreaElement | undefined = $state()
let charCount = $derived(String(value).length)
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
const wrapperClasses = $derived(() => {
const classes = ['input-wrapper']
@ -112,8 +90,6 @@
if (prefixIcon) classes.push('has-prefix-icon')
if (suffixIcon) classes.push('has-suffix-icon')
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 (className) classes.push(className)
return classes.join(' ')
@ -126,11 +102,6 @@
if (inputClass) classes.push(inputClass)
return classes.join(' ')
})
// Type guard for textarea props
function isTextarea(props: Props): props is TextareaProps {
return props.type === 'textarea'
}
</script>
<div class={wrapperClasses()}>
@ -161,32 +132,17 @@
></span>
{/if}
{#if type === 'textarea' && isTextarea(restProps)}
<textarea
bind:this={textareaElement}
bind:value
{id}
{disabled}
{readonly}
{required}
{maxLength}
class={inputClasses()}
rows={restProps.rows || 3}
{...restProps}
/>
{:else}
<input
bind:value
{id}
{type}
{disabled}
{readonly}
{required}
{maxLength}
class={inputClasses()}
{...restProps}
/>
{/if}
<input
bind:value
{id}
{type}
{disabled}
{readonly}
{required}
{maxLength}
class={inputClasses()}
{...restProps}
/>
{#if suffixIcon}
<span class="input-icon suffix-icon">
@ -303,23 +259,27 @@
}
}
// Input and textarea styles
// Input styles
.input {
width: 100%;
font-size: 14px;
border: 1px solid $grey-80;
border-radius: 6px;
background-color: white;
border: 1px solid transparent;
color: $input-text-color;
background-color: $input-background-color;
transition: all 0.15s ease;
&:hover {
background-color: $input-background-color-hover;
color: $input-text-color-hover;
}
&::placeholder {
color: $grey-50;
}
&:focus {
outline: none;
border-color: $primary-color;
background-color: white;
background-color: $input-background-color-hover;
color: $input-text-color-hover;
}
&:disabled {
@ -337,17 +297,23 @@
// Size variations
.input-small {
padding: $unit calc($unit * 1.5);
font-size: 13px;
font-size: 0.75rem;
}
.input-medium {
padding: calc($unit * 1.5) $unit-2x;
font-size: 14px;
font-size: 1rem;
}
.input-large {
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;
}
@ -362,17 +328,23 @@
&.input-large {
border-radius: 28px;
}
&.input-jumbo {
border-radius: 32px;
}
}
.input:not(.input-pill) {
&.input-small {
border-radius: 6px;
border-radius: $corner-radius-lg;
}
&.input-medium {
border-radius: 8px;
border-radius: $corner-radius-2xl;
}
&.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
.input-footer {
display: flex;

View file

@ -7,10 +7,11 @@
interface Props {
formData: ProjectFormData
validationErrors: Record<string, string>
onSave?: () => Promise<void>
}
let { formData = $bindable(), onSave }: Props = $props()
let { formData = $bindable(), validationErrors, onSave }: Props = $props()
// State for collapsible logo section
let showLogoSection = $state(!!formData.logoUrl && formData.logoUrl.trim() !== '')
@ -57,7 +58,7 @@
formData.logoUrl = ''
logoMedia = null
showLogoSection = false
// Auto-save the removal
if (onSave) {
await onSave()
@ -72,7 +73,7 @@
<Button
variant="secondary"
buttonSize="medium"
onclick={() => showLogoSection = true}
onclick={() => (showLogoSection = true)}
iconPosition="left"
>
<svg
@ -110,6 +111,30 @@
/>
</div>
{/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>
<style lang="scss">
@ -136,7 +161,6 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $unit-2x;
h3 {
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>

View file

@ -7,7 +7,7 @@
import Editor from './Editor.svelte'
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
import ProjectStylingForm from './ProjectStylingForm.svelte'
import ProjectImagesForm from './ProjectImagesForm.svelte'
import Button from './Button.svelte'
import StatusDropdown from './StatusDropdown.svelte'
import { projectSchema } from '$lib/schemas/project'
@ -264,8 +264,8 @@
}}
>
<ProjectMetadataForm bind:formData {validationErrors} onSave={handleSave} />
<ProjectBrandingForm bind:formData onSave={handleSave} />
<ProjectStylingForm bind:formData {validationErrors} />
<ProjectBrandingForm bind:formData {validationErrors} onSave={handleSave} />
<ProjectImagesForm bind:formData {validationErrors} onSave={handleSave} />
</form>
</div>
</div>

View 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>

View file

@ -1,10 +1,9 @@
<script lang="ts">
import Input from './Input.svelte'
import Textarea from './Textarea.svelte'
import SelectField from './SelectField.svelte'
import ImageUploader from './ImageUploader.svelte'
import Button from './Button.svelte'
import SegmentedControlField from './SegmentedControlField.svelte'
import type { ProjectFormData } from '$lib/types/project'
import type { Media } from '@prisma/client'
interface Props {
formData: ProjectFormData
@ -14,90 +13,46 @@
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">
<Input
label="Title"
required
size="jumbo"
error={validationErrors.title}
bind:value={formData.title}
placeholder="Project title"
/>
<Input
type="textarea"
<Textarea
label="Description"
size="jumbo"
error={validationErrors.description}
bind:value={formData.description}
rows={3}
placeholder="Short description for project cards"
/>
<SelectField
label="Project Type"
bind:value={formData.projectType}
error={validationErrors.projectType}
options={[
{ value: 'work', label: 'Work' },
{ value: 'labs', label: 'Labs' }
]}
helpText="Choose whether this project appears in the Work tab or Labs tab"
<Input
type="url"
label="External URL"
error={validationErrors.externalUrl}
bind:value={formData.externalUrl}
placeholder="https://example.com"
/>
<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
type="number"
label="Year"
@ -116,52 +71,6 @@
/>
</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'}
<Input
type="password"
@ -179,40 +88,32 @@
.form-section {
display: flex;
flex-direction: column;
gap: $unit-2x;
gap: $unit-4x;
&:last-child {
margin-bottom: 0;
}
h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: $grey-10;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $unit-2x;
gap: $unit-4x;
padding-bottom: $unit-3x;
&.three-column {
grid-template-columns: 1fr 1fr 1fr;
}
@include breakpoint('phone') {
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>

View 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>

View 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>