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:
2
src/shared/composables/index.ts
Normal file
2
src/shared/composables/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './useBodyScrollLock';
|
||||
export * from './useTextAreaAutosize';
|
||||
68
src/shared/composables/useTextAreaAutosize.ts
Normal file
68
src/shared/composables/useTextAreaAutosize.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
19
src/shared/ui/Badge/Badge.vue
Normal file
19
src/shared/ui/Badge/Badge.vue
Normal 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>
|
||||
2
src/shared/ui/Badge/index.ts
Normal file
2
src/shared/ui/Badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from './Badge.vue';
|
||||
export * from './types';
|
||||
6
src/shared/ui/Badge/types.ts
Normal file
6
src/shared/ui/Badge/types.ts
Normal 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;
|
||||
21
src/shared/ui/Button/Button.vue
Normal file
21
src/shared/ui/Button/Button.vue
Normal 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>
|
||||
2
src/shared/ui/Button/index.ts
Normal file
2
src/shared/ui/Button/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Button } from './Button.vue';
|
||||
export * from './types';
|
||||
22
src/shared/ui/Button/types.ts
Normal file
22
src/shared/ui/Button/types.ts
Normal 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'];
|
||||
}
|
||||
46
src/shared/ui/Input/Input.vue
Normal file
46
src/shared/ui/Input/Input.vue
Normal 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>
|
||||
1
src/shared/ui/Input/index.ts
Normal file
1
src/shared/ui/Input/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.vue';
|
||||
50
src/shared/ui/Textarea/Textarea.vue
Normal file
50
src/shared/ui/Textarea/Textarea.vue
Normal 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>
|
||||
1
src/shared/ui/Textarea/index.ts
Normal file
1
src/shared/ui/Textarea/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Textarea } from './Textarea.vue';
|
||||
Reference in New Issue
Block a user