1
0
mirror of https://github.com/robonen/lorem-blog.git synced 2026-03-20 10:54:38 +00:00

feat(ui): add Input, Textarea, Button, and Badge components with styles and props

This commit is contained in:
2025-06-15 15:37:29 +07:00
parent fc7eacffbd
commit e528230264
12 changed files with 240 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
export * from './useBodyScrollLock';
export * from './useTextAreaAutosize';

View File

@@ -0,0 +1,68 @@
import { clamp } from '@robonen/stdlib';
import { nextTick, onMounted, onUnmounted, ref, type TemplateRef, watch } from 'vue';
export function useTextAreaAutosize(textareaRef: TemplateRef<HTMLTextAreaElement | null>) {
const minHeight = ref(0);
const maxHeight = ref(Infinity);
const resize = () => nextTick(() => {
const textarea = textareaRef.value;
if (!textarea)
return;
textarea.style.height = 'auto';
const newHeight = clamp(textarea.scrollHeight, minHeight.value, maxHeight.value);
textarea.style.height = `${newHeight}px`;
textarea.style.overflowY = newHeight >= maxHeight.value ? 'auto' : 'hidden';
});
const handleInput = () => {
resize();
};
onMounted(() => {
const textarea = textareaRef.value;
if (textarea) {
minHeight.value = textarea.offsetHeight;
textarea.addEventListener('input', handleInput);
textarea.addEventListener('paste', handleInput);
resize();
}
});
onUnmounted(() => {
const textarea = textareaRef.value;
if (textarea) {
textarea.removeEventListener('input', handleInput);
textarea.removeEventListener('paste', handleInput);
}
});
watch(textareaRef, (newTextarea) => {
if (newTextarea) {
minHeight.value = newTextarea.offsetHeight;
resize();
}
});
const setMinHeight = (height: number) => {
minHeight.value = height;
resize();
};
const setMaxHeight = (height: number) => {
maxHeight.value = height;
resize();
};
return {
resize,
setMinHeight,
setMaxHeight,
};
}

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { type BadgeVariant, badgeVariants } from './types';
export interface BadgeProps {
variant?: BadgeVariant;
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<BadgeProps>(), {
variant: 'secondary',
});
</script>
<template>
<span class="px-3 py-1 text-xs font-semibold rounded-full transition-colors" :class="badgeVariants[props.variant]">
<slot />
</span>
</template>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from './Badge.vue';
export * from './types';

View File

@@ -0,0 +1,6 @@
export const badgeVariants = {
primary: 'bg-primary text-white',
secondary: 'bg-primary-light text-primary-dark',
};
export type BadgeVariant = keyof typeof badgeVariants;

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { computed } from 'vue';
import { type ButtonProps, buttonSizes, buttonVariants } from './types';
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary',
size: 'base',
type: 'button',
});
const styles = computed(() => [
buttonVariants[props.variant],
buttonSizes[props.size],
]);
</script>
<template>
<button class="rounded cursor-pointer transition-colors" :class="styles" :type="props.type">
<slot />
</button>
</template>

View File

@@ -0,0 +1,2 @@
export { default as Button } from './Button.vue';
export * from './types';

View File

@@ -0,0 +1,22 @@
import type { ButtonHTMLAttributes } from 'vue';
export const buttonVariants = {
primary: 'px-4 py-2 bg-primary text-white disabled:bg-gray-100 disabled:text-gray-400',
secondary: 'px-4 py-2 bg-primary-light text-primary-dark disabled:bg-gray-100 disabled:text-gray-400',
ghost: '',
};
export type ButtonVariant = keyof typeof buttonVariants;
export const buttonSizes = {
base: '',
icon: '',
};
export type ButtonSize = keyof typeof buttonSizes;
export interface ButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
type?: ButtonHTMLAttributes['type'];
}

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { InputHTMLAttributes } from 'vue';
import { XIcon } from '@/shared/icons';
import { Button } from '../Button';
export interface InputProps {
name: InputHTMLAttributes['name'];
placeholder?: InputHTMLAttributes['placeholder'];
type?: InputHTMLAttributes['type'];
}
</script>
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(defineProps<InputProps>(), {
type: 'text',
});
const model = defineModel<string>();
function clear() {
model.value = '';
}
</script>
<template>
<div class="w-full relative">
<Button
v-if="model?.length"
variant="ghost"
class="absolute top-2 right-1 text-foreground-muted"
@click="clear"
>
<XIcon class="size-5" />
<span class="sr-only">Очистить</span>
</Button>
<input
v-model="model"
class="w-full p-2 pr-5 bg-gray-100 placeholder-gray-400 text-sm rounded outline-primary"
v-bind="{ ...$attrs, ...props }"
>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as Input } from './Input.vue';

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import type { TextareaHTMLAttributes } from 'vue';
export interface InputProps {
name: TextareaHTMLAttributes['name'];
}
</script>
<script setup lang="ts">
import { useTemplateRef, watch } from 'vue';
import { useTextAreaAutosize } from '@/shared/composables';
import { XIcon } from '@/shared/icons';
import { Button } from '../Button';
defineOptions({
inheritAttrs: false,
});
const props = defineProps<InputProps>();
const model = defineModel<string>();
const textarea = useTemplateRef('textarea');
const { resize } = useTextAreaAutosize(textarea);
watch(model, resize);
function clear() {
model.value = '';
}
</script>
<template>
<div class="relative w-full">
<Button
v-if="model?.length"
variant="ghost"
class="absolute top-2 right-1 text-foreground-muted"
@click="clear"
>
<XIcon class="size-5" />
<span class="sr-only">Очистить</span>
</Button>
<textarea
ref="textarea"
v-model="model"
class="w-full pl-4 pr-6 py-2 resize-none text-sm rounded border border-gray-200 outline-primary"
v-bind="{ ...$attrs, ...props }"
/>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as Textarea } from './Textarea.vue';