mirror of
https://github.com/robonen/lorem-blog.git
synced 2026-03-20 02:44:39 +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