From e528230264a28306141f91ecce2abdb81440b7db Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 15 Jun 2025 15:37:29 +0700 Subject: [PATCH] feat(ui): add Input, Textarea, Button, and Badge components with styles and props --- src/shared/composables/index.ts | 2 + src/shared/composables/useTextAreaAutosize.ts | 68 +++++++++++++++++++ src/shared/ui/Badge/Badge.vue | 19 ++++++ src/shared/ui/Badge/index.ts | 2 + src/shared/ui/Badge/types.ts | 6 ++ src/shared/ui/Button/Button.vue | 21 ++++++ src/shared/ui/Button/index.ts | 2 + src/shared/ui/Button/types.ts | 22 ++++++ src/shared/ui/Input/Input.vue | 46 +++++++++++++ src/shared/ui/Input/index.ts | 1 + src/shared/ui/Textarea/Textarea.vue | 50 ++++++++++++++ src/shared/ui/Textarea/index.ts | 1 + 12 files changed, 240 insertions(+) create mode 100644 src/shared/composables/index.ts create mode 100644 src/shared/composables/useTextAreaAutosize.ts create mode 100644 src/shared/ui/Badge/Badge.vue create mode 100644 src/shared/ui/Badge/index.ts create mode 100644 src/shared/ui/Badge/types.ts create mode 100644 src/shared/ui/Button/Button.vue create mode 100644 src/shared/ui/Button/index.ts create mode 100644 src/shared/ui/Button/types.ts create mode 100644 src/shared/ui/Input/Input.vue create mode 100644 src/shared/ui/Input/index.ts create mode 100644 src/shared/ui/Textarea/Textarea.vue create mode 100644 src/shared/ui/Textarea/index.ts diff --git a/src/shared/composables/index.ts b/src/shared/composables/index.ts new file mode 100644 index 0000000..bbed4dd --- /dev/null +++ b/src/shared/composables/index.ts @@ -0,0 +1,2 @@ +export * from './useBodyScrollLock'; +export * from './useTextAreaAutosize'; diff --git a/src/shared/composables/useTextAreaAutosize.ts b/src/shared/composables/useTextAreaAutosize.ts new file mode 100644 index 0000000..323c9a5 --- /dev/null +++ b/src/shared/composables/useTextAreaAutosize.ts @@ -0,0 +1,68 @@ +import { clamp } from '@robonen/stdlib'; +import { nextTick, onMounted, onUnmounted, ref, type TemplateRef, watch } from 'vue'; + +export function useTextAreaAutosize(textareaRef: TemplateRef) { + 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, + }; +} diff --git a/src/shared/ui/Badge/Badge.vue b/src/shared/ui/Badge/Badge.vue new file mode 100644 index 0000000..1bd97fd --- /dev/null +++ b/src/shared/ui/Badge/Badge.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/src/shared/ui/Badge/index.ts b/src/shared/ui/Badge/index.ts new file mode 100644 index 0000000..80815e5 --- /dev/null +++ b/src/shared/ui/Badge/index.ts @@ -0,0 +1,2 @@ +export { default as Badge } from './Badge.vue'; +export * from './types'; diff --git a/src/shared/ui/Badge/types.ts b/src/shared/ui/Badge/types.ts new file mode 100644 index 0000000..a19aa13 --- /dev/null +++ b/src/shared/ui/Badge/types.ts @@ -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; diff --git a/src/shared/ui/Button/Button.vue b/src/shared/ui/Button/Button.vue new file mode 100644 index 0000000..0d27d66 --- /dev/null +++ b/src/shared/ui/Button/Button.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/shared/ui/Button/index.ts b/src/shared/ui/Button/index.ts new file mode 100644 index 0000000..ca51528 --- /dev/null +++ b/src/shared/ui/Button/index.ts @@ -0,0 +1,2 @@ +export { default as Button } from './Button.vue'; +export * from './types'; diff --git a/src/shared/ui/Button/types.ts b/src/shared/ui/Button/types.ts new file mode 100644 index 0000000..22f57b6 --- /dev/null +++ b/src/shared/ui/Button/types.ts @@ -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']; +} diff --git a/src/shared/ui/Input/Input.vue b/src/shared/ui/Input/Input.vue new file mode 100644 index 0000000..74fd8bc --- /dev/null +++ b/src/shared/ui/Input/Input.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/shared/ui/Input/index.ts b/src/shared/ui/Input/index.ts new file mode 100644 index 0000000..c5248c5 --- /dev/null +++ b/src/shared/ui/Input/index.ts @@ -0,0 +1 @@ +export { default as Input } from './Input.vue'; diff --git a/src/shared/ui/Textarea/Textarea.vue b/src/shared/ui/Textarea/Textarea.vue new file mode 100644 index 0000000..42b5c81 --- /dev/null +++ b/src/shared/ui/Textarea/Textarea.vue @@ -0,0 +1,50 @@ + + + + +