refactor: migrate all input fields to StyledInput (#5306)
* feat: StyledInput component * migrate: auth pages to styledInput * migrate: search/filter inputs * migrate: dashboard inputs * migrate: app frontend * migrate: search related inputs * migrate: all of app-frontend * fix: missing inputs on app-frontend * migrate: frontend * feat: multiline * migrate: textareas * fix: storybook use text-primary * fix: lint * fix: merge conflict * feat: cleanup
This commit is contained in:
@@ -57,18 +57,17 @@
|
||||
@keydown="handleDropdownKeydown"
|
||||
>
|
||||
<div v-if="searchable" class="p-4">
|
||||
<div class="iconified-input w-full border-surface-5 border-[1px] border-solid rounded-xl">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="searchPlaceholder"
|
||||
class=""
|
||||
@keydown.stop="handleSearchKeydown"
|
||||
@input="emit('searchInput', searchQuery)"
|
||||
/>
|
||||
</div>
|
||||
<StyledInput
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="searchPlaceholder"
|
||||
wrapper-class="w-full"
|
||||
input-class="!border !border-solid !border-surface-5"
|
||||
@keydown.stop="handleSearchKeydown"
|
||||
@input="emit('searchInput', searchQuery)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="searchable && filteredOptions.length > 0" class="h-px bg-surface-5"></div>
|
||||
@@ -134,6 +133,8 @@ import {
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
import StyledInput from './StyledInput.vue'
|
||||
|
||||
export interface ComboboxOption<T> {
|
||||
value: T
|
||||
label: string
|
||||
|
||||
@@ -18,22 +18,21 @@
|
||||
<slot />
|
||||
<DropdownIcon class="h-5 w-5 text-secondary" />
|
||||
<template #menu>
|
||||
<div v-if="search" class="iconified-input mb-2 w-full">
|
||||
<label for="search-input" hidden>Search...</label>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
id="search-input"
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search..."
|
||||
type="text"
|
||||
@keydown.enter="
|
||||
() => {
|
||||
toggleOption(filteredOptions[0])
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<StyledInput
|
||||
v-if="search"
|
||||
id="search-input"
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
placeholder="Search..."
|
||||
type="text"
|
||||
wrapper-class="mb-2 w-full"
|
||||
@keydown.enter="
|
||||
() => {
|
||||
toggleOption(filteredOptions[0])
|
||||
}
|
||||
"
|
||||
/>
|
||||
<ScrollablePanel v-if="search">
|
||||
<Button
|
||||
v-for="(option, index) in filteredOptions"
|
||||
@@ -75,7 +74,7 @@
|
||||
import { CheckIcon, DropdownIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { Button, ButtonStyled, PopoutMenu } from '../index'
|
||||
import { Button, ButtonStyled, PopoutMenu, StyledInput } from '../index'
|
||||
import ScrollablePanel from './ScrollablePanel.vue'
|
||||
|
||||
type Option = string | number | object
|
||||
|
||||
@@ -4,29 +4,28 @@
|
||||
<label class="label" for="insert-link-label">
|
||||
<span class="label__title">Label</span>
|
||||
</label>
|
||||
<div class="iconified-input">
|
||||
<AlignLeftIcon />
|
||||
<input id="insert-link-label" v-model="linkText" type="text" placeholder="Enter label..." />
|
||||
<Button class="r-btn" @click="() => (linkText = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="insert-link-label"
|
||||
v-model="linkText"
|
||||
:icon="AlignLeftIcon"
|
||||
type="text"
|
||||
placeholder="Enter label..."
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<label class="label" for="insert-link-url">
|
||||
<span class="label__title">URL<span class="required">*</span></span>
|
||||
</label>
|
||||
<div class="iconified-input">
|
||||
<LinkIcon />
|
||||
<input
|
||||
id="insert-link-url"
|
||||
v-model="linkUrl"
|
||||
type="text"
|
||||
placeholder="Enter the link's URL..."
|
||||
@input="validateURL"
|
||||
/>
|
||||
<Button class="r-btn" @click="() => (linkUrl = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="insert-link-url"
|
||||
v-model="linkUrl"
|
||||
:icon="LinkIcon"
|
||||
type="text"
|
||||
placeholder="Enter the link's URL..."
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
@input="validateURL"
|
||||
/>
|
||||
<template v-if="linkValidationErrorMessage">
|
||||
<span class="label">
|
||||
<span class="label__title">Error</span>
|
||||
@@ -68,18 +67,15 @@
|
||||
Describe the image completely as you would to someone who could not see the image.
|
||||
</span>
|
||||
</label>
|
||||
<div class="iconified-input">
|
||||
<AlignLeftIcon />
|
||||
<input
|
||||
id="insert-image-alt"
|
||||
v-model="linkText"
|
||||
type="text"
|
||||
placeholder="Describe the image..."
|
||||
/>
|
||||
<Button class="r-btn" @click="() => (linkText = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="insert-image-alt"
|
||||
v-model="linkText"
|
||||
:icon="AlignLeftIcon"
|
||||
type="text"
|
||||
placeholder="Describe the image..."
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<label class="label" for="insert-link-url">
|
||||
<span class="label__title">URL<span class="required">*</span></span>
|
||||
</label>
|
||||
@@ -101,19 +97,17 @@
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
</div>
|
||||
<div v-if="!props.onImageUpload || imageUploadOption === 'link'" class="iconified-input">
|
||||
<ImageIcon />
|
||||
<input
|
||||
id="insert-link-url"
|
||||
v-model="linkUrl"
|
||||
type="text"
|
||||
placeholder="Enter the image URL..."
|
||||
@input="validateURL"
|
||||
/>
|
||||
<Button class="r-btn" @click="() => (linkUrl = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<StyledInput
|
||||
v-if="!props.onImageUpload || imageUploadOption === 'link'"
|
||||
id="insert-link-url"
|
||||
v-model="linkUrl"
|
||||
:icon="ImageIcon"
|
||||
type="text"
|
||||
placeholder="Enter the image URL..."
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
@input="validateURL"
|
||||
/>
|
||||
<template v-if="linkValidationErrorMessage">
|
||||
<span class="label">
|
||||
<span class="label__title">Error</span>
|
||||
@@ -154,19 +148,16 @@
|
||||
<span class="label__title">YouTube video URL<span class="required">*</span></span>
|
||||
<span class="label__description"> Enter a valid link to a YouTube video. </span>
|
||||
</label>
|
||||
<div class="iconified-input">
|
||||
<YouTubeIcon />
|
||||
<input
|
||||
id="insert-video-url"
|
||||
v-model="linkUrl"
|
||||
type="text"
|
||||
placeholder="Enter YouTube video URL"
|
||||
@input="validateURL"
|
||||
/>
|
||||
<Button class="r-btn" @click="() => (linkUrl = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="insert-video-url"
|
||||
v-model="linkUrl"
|
||||
:icon="YouTubeIcon"
|
||||
type="text"
|
||||
placeholder="Enter YouTube video URL"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
@input="validateURL"
|
||||
/>
|
||||
<template v-if="linkValidationErrorMessage">
|
||||
<span class="label">
|
||||
<span class="label__title">Error</span>
|
||||
@@ -202,7 +193,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div class="resizable-textarea-wrapper">
|
||||
<div class="block grow w-full">
|
||||
<div class="editor-action-row">
|
||||
<div class="editor-actions">
|
||||
<template
|
||||
@@ -300,6 +291,7 @@ import NewModal from '../modal/NewModal.vue'
|
||||
import Button from './Button.vue'
|
||||
import Chips from './Chips.vue'
|
||||
import FileInput from './FileInput.vue'
|
||||
import StyledInput from './StyledInput.vue'
|
||||
import Toggle from './Toggle.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -894,11 +886,6 @@ function openVideoModal() {
|
||||
}
|
||||
}
|
||||
|
||||
.resizable-textarea-wrapper textarea {
|
||||
min-height: 10rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-blurb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -928,10 +915,6 @@ function openVideoModal() {
|
||||
.modal-insert {
|
||||
padding: var(--gap-lg);
|
||||
|
||||
.iconified-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-block: var(--gap-lg) var(--gap-sm);
|
||||
display: block;
|
||||
|
||||
181
packages/ui/src/components/base/StyledInput.vue
Normal file
181
packages/ui/src/components/base/StyledInput.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative"
|
||||
:class="[
|
||||
wrapperClass,
|
||||
multiline ? 'flex' : 'inline-flex',
|
||||
{ 'opacity-50 cursor-not-allowed': disabled },
|
||||
!multiline && variant === 'outlined' ? 'items-stretch' : 'items-center',
|
||||
]"
|
||||
>
|
||||
<!-- Left icon (filled variant, single-line only) -->
|
||||
<component
|
||||
:is="icon"
|
||||
v-if="icon && variant === 'filled' && !multiline"
|
||||
class="absolute left-3 h-5 w-5 z-[1] pointer-events-none transition-colors"
|
||||
:class="[isFocused ? 'opacity-100 text-contrast' : 'opacity-60 text-secondary']"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Multiline textarea -->
|
||||
<textarea
|
||||
v-if="multiline"
|
||||
ref="inputRef"
|
||||
:id="id"
|
||||
:value="model"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:maxlength="maxlength"
|
||||
:rows="rows"
|
||||
class="w-full text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow bg-surface-4 border-none rounded-xl"
|
||||
:class="[
|
||||
inputClass,
|
||||
'pl-3 pr-3 py-2 text-base',
|
||||
error ? 'outline outline-2 outline-red bg-warning-bg' : 'outline-none',
|
||||
disabled ? 'cursor-not-allowed' : '',
|
||||
resizeClass,
|
||||
]"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
/>
|
||||
|
||||
<!-- Single-line input -->
|
||||
<input
|
||||
v-else
|
||||
ref="inputRef"
|
||||
:id="id"
|
||||
:type="type"
|
||||
:value="model"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:inputmode="inputmode"
|
||||
:maxlength="maxlength"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="w-full text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow"
|
||||
:class="[
|
||||
inputClass,
|
||||
variant === 'filled' && icon ? 'pl-10' : 'pl-3',
|
||||
clearable && model && variant === 'filled' ? 'pr-8' : 'pr-3',
|
||||
size === 'small' ? 'h-8 py-1.5 text-sm' : 'h-9 py-2 text-base',
|
||||
error ? 'outline outline-2 outline-red bg-warning-bg' : 'outline-none',
|
||||
disabled ? 'cursor-not-allowed' : '',
|
||||
variant === 'outlined'
|
||||
? 'bg-transparent border border-solid border-button-bg rounded-l-xl border-r-0'
|
||||
: 'bg-surface-4 border-none rounded-xl',
|
||||
]"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
/>
|
||||
|
||||
<!-- Clear button (right side, filled variant, single-line only) -->
|
||||
<button
|
||||
v-if="!multiline && clearable && model && !disabled && !readonly && variant === 'filled'"
|
||||
type="button"
|
||||
class="absolute right-0.5 z-[1] p-2 bg-transparent border-none text-secondary hover:text-contrast transition-colors cursor-pointer select-none"
|
||||
aria-label="Clear input"
|
||||
@click="clear"
|
||||
>
|
||||
<XIcon class="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<!-- Right icon button (outlined variant, single-line only) -->
|
||||
<button
|
||||
v-if="!multiline && variant === 'outlined'"
|
||||
type="button"
|
||||
class="flex items-center justify-center px-2 bg-transparent border border-solid border-button-bg rounded-r-xl text-secondary hover:text-contrast transition-colors shrink-0"
|
||||
:aria-label="clearable && model ? 'Clear input' : 'Search'"
|
||||
:tabindex="clearable && model ? undefined : -1"
|
||||
@click="clearable && model ? clear() : undefined"
|
||||
>
|
||||
<XIcon v-if="clearable && model" class="h-4 w-4" />
|
||||
<component :is="icon" v-else-if="icon" class="h-4 w-4" />
|
||||
<SearchIcon v-else class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<!-- Custom rightside slot -->
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchIcon, XIcon } from '@modrinth/assets'
|
||||
import { type Component, computed, ref } from 'vue'
|
||||
|
||||
const model = defineModel<string | number | undefined>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon?: Component
|
||||
type?: 'text' | 'email' | 'password' | 'number' | 'url' | 'search' | 'date' | 'datetime-local'
|
||||
placeholder?: string
|
||||
id?: string
|
||||
name?: string
|
||||
autocomplete?: string
|
||||
inputmode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'
|
||||
maxlength?: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
error?: boolean
|
||||
size?: 'standard' | 'small'
|
||||
variant?: 'filled' | 'outlined'
|
||||
clearable?: boolean
|
||||
multiline?: boolean
|
||||
rows?: number
|
||||
resize?: 'none' | 'vertical' | 'both'
|
||||
inputClass?: string
|
||||
wrapperClass?: string
|
||||
}>(),
|
||||
{
|
||||
type: 'text',
|
||||
size: 'standard',
|
||||
variant: 'filled',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
error: false,
|
||||
clearable: false,
|
||||
multiline: false,
|
||||
rows: 3,
|
||||
resize: 'none',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | HTMLTextAreaElement>()
|
||||
const isFocused = ref(false)
|
||||
const resizeClass = computed(
|
||||
() => ({ none: 'resize-none', vertical: 'resize-y', both: 'resize' })[props.resize ?? 'none'],
|
||||
)
|
||||
|
||||
defineExpose({ focus: () => inputRef.value?.focus() })
|
||||
|
||||
function onInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement
|
||||
model.value =
|
||||
props.type === 'number' && !props.multiline
|
||||
? target.value === ''
|
||||
? undefined
|
||||
: Number(target.value)
|
||||
: target.value
|
||||
}
|
||||
|
||||
function clear() {
|
||||
model.value = props.type === 'number' && !props.multiline ? undefined : ''
|
||||
emit('clear')
|
||||
}
|
||||
</script>
|
||||
@@ -57,6 +57,7 @@ export { default as SettingsLabel } from './SettingsLabel.vue'
|
||||
export { default as SimpleBadge } from './SimpleBadge.vue'
|
||||
export { default as Slider } from './Slider.vue'
|
||||
export { default as SmartClickable } from './SmartClickable.vue'
|
||||
export { default as StyledInput } from './StyledInput.vue'
|
||||
export type { TableColumn } from './Table.vue'
|
||||
export { default as Table } from './Table.vue'
|
||||
export { default as TagItem } from './TagItem.vue'
|
||||
|
||||
Reference in New Issue
Block a user