From df9b6ff362e1afaa2fe880eb4ddfa76a95990741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Wed, 13 May 2026 11:44:48 +0300 Subject: [PATCH] New Components --- .../Seeds/ListFormSeeder_Administration.cs | 3 +- .../styles/components/_autocomplete.css | 64 +++ .../assets/styles/components/_breadcrumb.css | 49 ++ ui/src/assets/styles/components/_chips.css | 45 ++ .../styles/components/_color-picker.css | 89 ++++ .../styles/components/_image-viewer.css | 96 ++++ ui/src/assets/styles/components/_knob.css | 33 ++ ui/src/assets/styles/components/_marquee.css | 101 ++++ ui/src/assets/styles/components/_rate.css | 11 + ui/src/assets/styles/components/_slider.css | 124 +++++ ui/src/assets/styles/components/index.css | 9 + .../ui/AutoComplete/AutoComplete.tsx | 402 ++++++++++++++ ui/src/components/ui/AutoComplete/index.tsx | 2 + .../components/ui/Breadcrumb/Breadcrumb.tsx | 179 +++++++ ui/src/components/ui/Breadcrumb/index.tsx | 2 + ui/src/components/ui/Chips/Chips.tsx | 254 +++++++++ ui/src/components/ui/Chips/index.tsx | 2 + .../components/ui/ColorPicker/ColorPicker.tsx | 309 +++++++++++ ui/src/components/ui/ColorPicker/index.tsx | 2 + .../components/ui/ImageViewer/ImageViewer.tsx | 502 ++++++++++++++++++ ui/src/components/ui/ImageViewer/index.tsx | 2 + ui/src/components/ui/Knob/Knob.tsx | 329 ++++++++++++ ui/src/components/ui/Knob/index.tsx | 2 + ui/src/components/ui/Marquee/Marquee.tsx | 221 ++++++++ ui/src/components/ui/Marquee/index.tsx | 2 + ui/src/components/ui/Rate/Rate.tsx | 231 ++++++++ ui/src/components/ui/Rate/index.tsx | 2 + ui/src/components/ui/Slider/Slider.tsx | 380 +++++++++++++ ui/src/components/ui/Slider/index.tsx | 2 + ui/src/components/ui/index.ts | 18 + 30 files changed, 3466 insertions(+), 1 deletion(-) create mode 100644 ui/src/assets/styles/components/_autocomplete.css create mode 100644 ui/src/assets/styles/components/_breadcrumb.css create mode 100644 ui/src/assets/styles/components/_chips.css create mode 100644 ui/src/assets/styles/components/_color-picker.css create mode 100644 ui/src/assets/styles/components/_image-viewer.css create mode 100644 ui/src/assets/styles/components/_knob.css create mode 100644 ui/src/assets/styles/components/_marquee.css create mode 100644 ui/src/assets/styles/components/_rate.css create mode 100644 ui/src/assets/styles/components/_slider.css create mode 100644 ui/src/components/ui/AutoComplete/AutoComplete.tsx create mode 100644 ui/src/components/ui/AutoComplete/index.tsx create mode 100644 ui/src/components/ui/Breadcrumb/Breadcrumb.tsx create mode 100644 ui/src/components/ui/Breadcrumb/index.tsx create mode 100644 ui/src/components/ui/Chips/Chips.tsx create mode 100644 ui/src/components/ui/Chips/index.tsx create mode 100644 ui/src/components/ui/ColorPicker/ColorPicker.tsx create mode 100644 ui/src/components/ui/ColorPicker/index.tsx create mode 100644 ui/src/components/ui/ImageViewer/ImageViewer.tsx create mode 100644 ui/src/components/ui/ImageViewer/index.tsx create mode 100644 ui/src/components/ui/Knob/Knob.tsx create mode 100644 ui/src/components/ui/Knob/index.tsx create mode 100644 ui/src/components/ui/Marquee/Marquee.tsx create mode 100644 ui/src/components/ui/Marquee/index.tsx create mode 100644 ui/src/components/ui/Rate/Rate.tsx create mode 100644 ui/src/components/ui/Rate/index.tsx create mode 100644 ui/src/components/ui/Slider/Slider.tsx create mode 100644 ui/src/components/ui/Slider/index.tsx diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Administration.cs b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Administration.cs index c99d012..57392a9 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Administration.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Administration.cs @@ -460,7 +460,8 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep InsertCommand = "INSERT INTO \"AbpClaimTypes\" (\"Id\",\"ValueType\",\"Required\",\"IsStatic\",\"Name\",\"ConcurrencyStamp\",\"ExtraProperties\") OUTPUT Inserted.Id VALUES (@Id,@ValueType,@Required,@IsStatic,@Name,@ConcurrencyStamp,@ExtraProperties)", InsertFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] { new() { FieldName = "ConcurrencyStamp", FieldDbType = DbType.Guid, Value = Guid.NewGuid().ToString(), CustomValueType = FieldCustomValueTypeEnum.Value }, - new() { FieldName = "ExtraProperties", FieldDbType = DbType.String, Value = "{}", CustomValueType = FieldCustomValueTypeEnum.Value } + new() { FieldName = "ExtraProperties", FieldDbType = DbType.String, Value = "{}", CustomValueType = FieldCustomValueTypeEnum.Value }, + new() { FieldName = "Id", FieldDbType = DbType.Guid, Value = "@NEWID", CustomValueType = FieldCustomValueTypeEnum.CustomKey } }), FormFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] { new() { FieldName = "Required", FieldDbType = DbType.Boolean, Value = "false", CustomValueType = FieldCustomValueTypeEnum.Value }, diff --git a/ui/src/assets/styles/components/_autocomplete.css b/ui/src/assets/styles/components/_autocomplete.css new file mode 100644 index 0000000..22fab1c --- /dev/null +++ b/ui/src/assets/styles/components/_autocomplete.css @@ -0,0 +1,64 @@ +.autocomplete { + @apply relative w-full; +} + +.autocomplete-input-wrapper { + @apply relative flex items-center; +} + +.autocomplete-input { + @apply w-full px-3 rounded-lg border border-gray-300 dark:border-gray-600 + bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 + placeholder-gray-400 dark:placeholder-gray-500 + focus:outline-none focus:ring-2 focus:border-transparent + transition-colors duration-150 text-sm; +} + +.autocomplete-input-disabled { + @apply opacity-50 cursor-not-allowed bg-gray-100 dark:bg-gray-700; +} + +.autocomplete-input-clearable { + @apply pr-8; +} + +.autocomplete-suffix { + @apply absolute right-2.5 flex items-center gap-1 pointer-events-none; +} + +.autocomplete-suffix > * { + @apply pointer-events-auto; +} + +.autocomplete-spinner { + @apply text-gray-400; +} + +.autocomplete-clear { + @apply text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors; +} + +/* Dropdown */ +.autocomplete-dropdown { + @apply absolute z-50 top-full left-0 right-0 mt-1 py-1 + rounded-lg border border-gray-200 dark:border-gray-700 + bg-white dark:bg-gray-800 shadow-lg + max-h-60 overflow-y-auto list-none m-0 p-0; +} + +.autocomplete-option { + @apply px-3 py-2 text-sm text-gray-800 dark:text-gray-200 + cursor-pointer select-none transition-colors duration-100; +} + +.autocomplete-option-active { + @apply bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300; +} + +.autocomplete-option-disabled { + @apply opacity-40 cursor-not-allowed pointer-events-none; +} + +.autocomplete-option-info { + @apply text-gray-400 dark:text-gray-500 cursor-default text-center py-3; +} diff --git a/ui/src/assets/styles/components/_breadcrumb.css b/ui/src/assets/styles/components/_breadcrumb.css new file mode 100644 index 0000000..7db8242 --- /dev/null +++ b/ui/src/assets/styles/components/_breadcrumb.css @@ -0,0 +1,49 @@ +.breadcrumb-nav { + @apply w-full; +} + +.breadcrumb-list { + @apply flex flex-wrap items-center list-none m-0 p-0; +} + +.breadcrumb-entry { + @apply flex items-center; +} + +.breadcrumb-separator { + @apply text-gray-400 dark:text-gray-500 select-none; +} + +.breadcrumb-collapsed { + @apply text-gray-400 dark:text-gray-500 cursor-default; +} + +.breadcrumb-item { + @apply flex items-center; +} + +.breadcrumb-link { + @apply flex items-center gap-1 no-underline transition-colors duration-150; +} + +.breadcrumb-link-default { + @apply text-gray-600 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400; +} + +.breadcrumb-link-active { + @apply text-gray-900 dark:text-gray-100 font-medium cursor-default pointer-events-none; +} + +.breadcrumb-icon { + @apply inline-flex items-center; +} + +/* Sizes */ +.breadcrumb-sm .breadcrumb-link { @apply text-xs; } +.breadcrumb-sm .breadcrumb-separator { @apply text-xs; } + +.breadcrumb-md .breadcrumb-link { @apply text-sm; } +.breadcrumb-md .breadcrumb-separator { @apply text-sm; } + +.breadcrumb-lg .breadcrumb-link { @apply text-base; } +.breadcrumb-lg .breadcrumb-separator { @apply text-base; } diff --git a/ui/src/assets/styles/components/_chips.css b/ui/src/assets/styles/components/_chips.css new file mode 100644 index 0000000..d482d25 --- /dev/null +++ b/ui/src/assets/styles/components/_chips.css @@ -0,0 +1,45 @@ +/* Container */ +.chips { + @apply flex flex-wrap items-center gap-1.5 px-2 py-1.5 w-full + rounded-lg border border-gray-300 dark:border-gray-600 + bg-white dark:bg-gray-800 + focus-within:ring-2 focus-within:border-transparent + cursor-text transition-colors duration-150; +} + +.chips-invalid { + @apply border-red-500 focus-within:ring-red-500; +} + +.chips-disabled { + @apply opacity-50 cursor-not-allowed bg-gray-100 dark:bg-gray-700; +} + +/* Chip item */ +.chips-item { + @apply inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-sm + bg-indigo-100 dark:bg-indigo-900/40 + text-indigo-700 dark:text-indigo-300 + select-none outline-none + focus-visible:ring-2 focus-visible:ring-indigo-500; +} + +.chips-item[data-focused='true'] { + @apply ring-2 ring-indigo-500; +} + +.chips-item-label { + @apply leading-none; +} + +.chips-item-remove { + @apply flex items-center justify-center rounded-full + text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-100 + transition-colors duration-100; +} + +/* Input */ +.chips-input { + @apply flex-1 min-w-[80px] outline-none bg-transparent text-sm + text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500; +} diff --git a/ui/src/assets/styles/components/_color-picker.css b/ui/src/assets/styles/components/_color-picker.css new file mode 100644 index 0000000..27687c5 --- /dev/null +++ b/ui/src/assets/styles/components/_color-picker.css @@ -0,0 +1,89 @@ +.color-picker { + @apply relative inline-flex items-center; +} + +/* Swatch button */ +.color-picker-swatch { + @apply w-8 h-8 rounded-md border border-gray-300 dark:border-gray-600 cursor-pointer + shadow-sm transition-transform duration-150 hover:scale-105 focus-visible:outline + focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500; +} + +.color-picker-disabled { + @apply opacity-50 cursor-not-allowed hover:scale-100; +} + +/* Sizes */ +.color-picker-sm .color-picker-swatch { @apply w-6 h-6 rounded; } +.color-picker-lg .color-picker-swatch { @apply w-10 h-10 rounded-lg; } +.color-picker-xs .color-picker-swatch { @apply w-5 h-5 rounded; } + +/* Native hidden input */ +.color-picker-native { + @apply absolute w-0 h-0 opacity-0 pointer-events-none; +} + +/* Popup panel */ +.color-picker-panel { + @apply absolute z-50 top-full left-0 mt-2 p-3 rounded-xl shadow-xl + bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 + flex flex-col gap-3 min-w-[200px]; +} + +/* Spectrum (native color input büyük) */ +.color-picker-spectrum-wrapper { + @apply flex justify-center; +} + +.color-picker-spectrum { + @apply w-full h-32 rounded-lg cursor-crosshair border-0 p-0; + -webkit-appearance: none; + appearance: none; +} + +.color-picker-spectrum::-webkit-color-swatch-wrapper { + @apply p-0; +} + +.color-picker-spectrum::-webkit-color-swatch { + @apply rounded-lg border-0; +} + +/* Presets */ +.color-picker-presets { + @apply flex flex-wrap gap-1.5; +} + +.color-picker-preset-dot { + @apply w-5 h-5 rounded-full border-2 border-transparent cursor-pointer + transition-transform duration-100 hover:scale-110 + focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-indigo-500; +} + +.color-picker-preset-dot-active { + @apply border-gray-700 dark:border-white scale-110; +} + +/* Text inputs */ +.color-picker-input-row { + @apply flex items-center gap-2; +} + +.color-picker-rgb-row { + @apply flex items-center gap-2; +} + +.color-picker-rgb-field { + @apply flex flex-col items-center gap-0.5 flex-1; +} + +.color-picker-text-input { + @apply w-full text-sm px-2 py-1 rounded-md border border-gray-300 dark:border-gray-600 + bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 + focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent + font-mono; +} + +.color-picker-input-label { + @apply text-xs text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wide w-8 shrink-0; +} diff --git a/ui/src/assets/styles/components/_image-viewer.css b/ui/src/assets/styles/components/_image-viewer.css new file mode 100644 index 0000000..efaf00c --- /dev/null +++ b/ui/src/assets/styles/components/_image-viewer.css @@ -0,0 +1,96 @@ +/* Overlay */ +.image-viewer-overlay { + @apply fixed inset-0 z-[9999] flex flex-col bg-black/90; + backdrop-filter: blur(4px); +} + +/* Toolbar */ +.image-viewer-toolbar { + @apply flex items-center gap-3 px-4 py-2 + bg-black/60 text-white text-sm shrink-0 z-10; +} + +.image-viewer-counter { + @apply font-mono text-white/70 shrink-0; +} + +.image-viewer-caption { + @apply flex-1 text-white/80 truncate; +} + +.image-viewer-zoom-label { + @apply font-mono text-xs text-white/60 w-10 text-center; +} + +.image-viewer-toolbar-actions { + @apply flex items-center gap-1 ml-auto; +} + +.image-viewer-toolbar-actions button { + @apply p-1.5 rounded text-white/70 hover:text-white hover:bg-white/10 + transition-colors duration-150; +} + +.image-viewer-close { + @apply ml-2 !text-white/90 hover:!text-red-400; +} + +/* Stage */ +.image-viewer-stage { + @apply flex-1 flex items-center justify-center overflow-hidden relative; +} + +.image-viewer-dragging { + @apply select-none; +} + +.image-viewer-img { + @apply max-w-full max-h-full object-contain transition-transform duration-150; + will-change: transform; +} + +/* Nav buttons */ +.image-viewer-nav { + @apply absolute top-1/2 -translate-y-1/2 z-10 + flex items-center justify-center + w-10 h-10 rounded-full + bg-black/40 hover:bg-black/70 + text-white/80 hover:text-white + transition-all duration-150; +} + +.image-viewer-nav-prev { @apply left-3; } +.image-viewer-nav-next { @apply right-3; } + +/* Thumbnails */ +.image-viewer-thumbnails { + @apply flex items-center justify-center gap-2 px-4 py-3 + bg-black/60 overflow-x-auto shrink-0; +} + +.image-viewer-thumb { + @apply w-14 h-14 rounded overflow-hidden border-2 border-transparent + opacity-50 hover:opacity-80 transition-all duration-150 shrink-0 p-0; +} + +.image-viewer-thumb-active { + @apply border-white opacity-100; +} + +.image-viewer-thumb img { + @apply w-full h-full object-cover; +} + +/* Trigger (galeri modu) */ +.image-viewer-trigger { + @apply flex flex-wrap gap-2; +} + +.image-viewer-trigger-item { + @apply overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 + cursor-pointer hover:opacity-80 transition-opacity duration-150 p-0; +} + +.image-viewer-trigger-item img { + @apply block w-full h-full object-cover; +} diff --git a/ui/src/assets/styles/components/_knob.css b/ui/src/assets/styles/components/_knob.css new file mode 100644 index 0000000..e9e6901 --- /dev/null +++ b/ui/src/assets/styles/components/_knob.css @@ -0,0 +1,33 @@ +.knob-wrapper { + @apply inline-flex flex-col items-center; +} + +.knob { + @apply select-none outline-none; + touch-action: none; +} + +.knob-interactive { + @apply cursor-grab; +} + +.knob-interactive:active { + @apply cursor-grabbing; +} + +.knob-disabled { + @apply opacity-50 cursor-not-allowed; +} + +.knob-range { + @apply text-gray-200 dark:text-gray-700; +} + +.knob-label { + @apply fill-gray-800 dark:fill-gray-100; +} + +.knob:focus-visible { + @apply outline-2 outline-offset-2 outline-indigo-500; + border-radius: 50%; +} diff --git a/ui/src/assets/styles/components/_marquee.css b/ui/src/assets/styles/components/_marquee.css new file mode 100644 index 0000000..8acb54f --- /dev/null +++ b/ui/src/assets/styles/components/_marquee.css @@ -0,0 +1,101 @@ +/* Marquee Container */ +.marquee-container { + @apply flex overflow-hidden; + width: 100%; + position: relative; +} + +.marquee-container.marquee-vertical { + @apply flex-col; + height: 100%; +} + +/* Gradient overlays */ +.marquee-container.marquee-gradient::before, +.marquee-container.marquee-gradient::after { + content: ''; + position: absolute; + z-index: 1; + pointer-events: none; +} + +.marquee-container:not(.marquee-vertical).marquee-gradient::before { + left: 0; + top: 0; + bottom: 0; + width: var(--marquee-gradient-width, 200px); + background: linear-gradient(to right, var(--marquee-gradient-color, rgba(255,255,255,1), rgba(255,255,255,0))); +} + +.marquee-container:not(.marquee-vertical).marquee-gradient::after { + right: 0; + top: 0; + bottom: 0; + width: var(--marquee-gradient-width, 200px); + background: linear-gradient(to left, var(--marquee-gradient-color, rgba(255,255,255,1), rgba(255,255,255,0))); +} + +.marquee-container.marquee-vertical.marquee-gradient::before { + top: 0; + left: 0; + right: 0; + height: var(--marquee-gradient-width, 200px); + background: linear-gradient(to bottom, var(--marquee-gradient-color, rgba(255,255,255,1), rgba(255,255,255,0))); +} + +.marquee-container.marquee-vertical.marquee-gradient::after { + bottom: 0; + left: 0; + right: 0; + height: var(--marquee-gradient-width, 200px); + background: linear-gradient(to top, var(--marquee-gradient-color, rgba(255,255,255,1), rgba(255,255,255,0))); +} + +/* Track */ +.marquee-track { + @apply flex shrink-0 items-center; + animation-duration: var(--marquee-duration, 20s); + animation-delay: var(--marquee-delay, 0s); + animation-iteration-count: var(--marquee-iteration-count, infinite); + animation-timing-function: linear; + animation-fill-mode: both; +} + +.marquee-vertical .marquee-track { + @apply flex-col; +} + +/* Animations */ +@keyframes marquee-scroll-left { + from { transform: translateX(0%); } + to { transform: translateX(-100%); } +} + +@keyframes marquee-scroll-right { + from { transform: translateX(-100%); } + to { transform: translateX(0%); } +} + +@keyframes marquee-scroll-up { + from { transform: translateY(0%); } + to { transform: translateY(-100%); } +} + +@keyframes marquee-scroll-down { + from { transform: translateY(-100%); } + to { transform: translateY(0%); } +} + +.marquee-animate-left { animation-name: marquee-scroll-left; } +.marquee-animate-right { animation-name: marquee-scroll-right; } +.marquee-animate-up { animation-name: marquee-scroll-up; } +.marquee-animate-down { animation-name: marquee-scroll-down; } + +/* Pause states */ +.marquee-paused { + animation-play-state: paused !important; +} + +.marquee-pause-on-hover:hover { + animation-play-state: paused; +} diff --git a/ui/src/assets/styles/components/_rate.css b/ui/src/assets/styles/components/_rate.css new file mode 100644 index 0000000..0c15b03 --- /dev/null +++ b/ui/src/assets/styles/components/_rate.css @@ -0,0 +1,11 @@ +.rate { + @apply inline-flex items-center select-none outline-none; +} + +.rate-star { + @apply inline-flex items-center transition-colors duration-150; +} + +.rate-star:focus-visible { + @apply outline-2 outline-offset-2 outline-indigo-500 rounded; +} diff --git a/ui/src/assets/styles/components/_slider.css b/ui/src/assets/styles/components/_slider.css new file mode 100644 index 0000000..9edddb8 --- /dev/null +++ b/ui/src/assets/styles/components/_slider.css @@ -0,0 +1,124 @@ +/* Horizontal */ +.slider { + @apply relative w-full; + touch-action: none; +} + +.slider-horizontal { + @apply flex flex-col justify-center; + padding-block: 10px; +} + +.slider-vertical { + @apply inline-flex flex-row justify-center; + padding-inline: 10px; + height: 200px; +} + +.slider-disabled { + @apply opacity-50 pointer-events-none; +} + +/* Track */ +.slider-track { + @apply relative bg-gray-200 dark:bg-gray-700 rounded-full cursor-pointer; +} + +.slider-horizontal .slider-track { + @apply w-full; +} + +.slider-vertical .slider-track { + @apply h-full; +} + +/* Fill */ +.slider-fill { + @apply absolute bg-indigo-500 dark:bg-indigo-400 rounded-full; + top: 0; + bottom: 0; +} + +.slider-vertical .slider-fill { + left: 0; + right: 0; +} + +/* Handle */ +.slider-handle { + @apply absolute rounded-full bg-white border-2 border-indigo-500 + shadow-md cursor-grab z-10 outline-none + focus:ring-2 focus:ring-offset-1 transition-shadow duration-100 + hover:shadow-lg; + top: 50%; + transform: translateY(-50%); +} + +.slider-vertical .slider-handle { + top: auto; + left: 50%; + transform: translateX(-50%); +} + +.slider-handle:active { + @apply cursor-grabbing shadow-lg; +} + +.slider-handle-disabled { + @apply cursor-not-allowed; +} + +/* Tooltip */ +.slider-tooltip { + @apply absolute -top-8 left-1/2 -translate-x-1/2 + px-1.5 py-0.5 text-xs font-medium + bg-gray-800 dark:bg-gray-200 + text-white dark:text-gray-900 + rounded whitespace-nowrap pointer-events-none z-20; +} + +.slider-tooltip::after { + content: ''; + @apply absolute left-1/2 -translate-x-1/2 -bottom-1; + border: 4px solid transparent; + border-top-color: theme('colors.gray.800'); +} + +.dark .slider-tooltip::after { + border-top-color: theme('colors.gray.200'); +} + +/* Marks */ +.slider-marks { + @apply relative w-full mt-2; + height: 20px; +} + +.slider-marks-vertical { + @apply h-full ml-2 mt-0; + width: 20px; +} + +.slider-mark-wrapper { + @apply absolute flex flex-col items-center -translate-x-1/2; +} + +.slider-marks-vertical .slider-mark-wrapper { + @apply flex-row translate-x-0 -translate-y-1/2; +} + +.slider-mark-dot { + @apply w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600; +} + +.slider-mark-dot-active { + @apply bg-indigo-400 dark:bg-indigo-500; +} + +.slider-mark-label { + @apply text-xs text-gray-500 dark:text-gray-400 mt-1 whitespace-nowrap; +} + +.slider-marks-vertical .slider-mark-label { + @apply mt-0 ml-1; +} diff --git a/ui/src/assets/styles/components/index.css b/ui/src/assets/styles/components/index.css index 4a85cf1..cadf8a3 100644 --- a/ui/src/assets/styles/components/index.css +++ b/ui/src/assets/styles/components/index.css @@ -1,26 +1,35 @@ @import "./_alert.css"; +@import "./_autocomplete.css"; @import "./_avatar.css"; @import "./_badge.css"; +@import "./_breadcrumb.css"; @import "./_button.css"; @import "./_card.css"; @import "./_checkbox.css"; +@import "./_chips.css"; @import "./_close-button.css"; +@import "./_color-picker.css"; @import "./_date-picker.css"; @import "./_dialog.css"; @import "./_drawer.css"; @import "./_dropdown.css"; @import "./_form.css"; +@import "./_image-viewer.css"; @import "./_input-group.css"; @import "./_input.css"; +@import "./_knob.css"; @import "./_menu-item.css"; @import "./_menu.css"; +@import "./_marquee.css"; @import "./_notification.css"; @import "./_pagination.css"; @import "./_progress.css"; @import "./_radio.css"; +@import "./_rate.css"; @import "./_segment.css"; @import "./_select.css"; @import "./_skeleton.css"; +@import "./_slider.css"; @import "./_steps.css"; @import "./_switcher.css"; @import "./_tables.css"; diff --git a/ui/src/components/ui/AutoComplete/AutoComplete.tsx b/ui/src/components/ui/AutoComplete/AutoComplete.tsx new file mode 100644 index 0000000..896ab4f --- /dev/null +++ b/ui/src/components/ui/AutoComplete/AutoComplete.tsx @@ -0,0 +1,402 @@ +import { + forwardRef, + useState, + useRef, + useCallback, + useEffect, + useId, + type KeyboardEvent, + type ChangeEvent, + type ReactNode, +} from 'react' +import classNames from 'classnames' +import { useConfig } from '../ConfigProvider' +import { useForm } from '../Form/context' +import { useInputGroup } from '../InputGroup/context' +import { CONTROL_SIZES } from '../utils/constants' +import { Spinner } from '../Spinner' +import type { CommonProps, TypeAttributes } from '../@types/common' + +// ── Tipler ────────────────────────────────────────────────────────────────── + +export interface AutoCompleteOption { + label: string + value: string + disabled?: boolean + /** Özel veri (filtreleme / render için) */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: any +} + +export interface AutoCompleteProps extends CommonProps { + /** Statik seçenek listesi */ + options?: AutoCompleteOption[] + /** Kontrollü input değeri */ + value?: string + /** Başlangıç değeri (kontrolsüz) */ + defaultValue?: string + /** Placeholder */ + placeholder?: string + /** Devre dışı */ + disabled?: boolean + /** Geçersiz (kırmızı kenarlık) */ + invalid?: boolean + /** Input boyutu */ + size?: TypeAttributes.ControlSize + /** Async seçenek yükleme - (inputValue) => Promise */ + fetchOptions?: (query: string) => Promise + /** Async istekleri geciktir (ms). Varsayılan: 300 */ + debounce?: number + /** Seçenek özelleştirme render */ + renderOption?: (option: AutoCompleteOption, active: boolean) => ReactNode + /** "Seçenek bulunamadı" metni */ + noOptionsText?: string + /** Yükleniyor metni */ + loadingText?: string + /** Input değeri değiştiğinde */ + onInputChange?: (value: string) => void + /** Bir seçenek seçildiğinde */ + onSelect?: (option: AutoCompleteOption) => void + /** Temizle butonu göster */ + clearable?: boolean + /** Input adı */ + name?: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + field?: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + form?: any + /** Minimum karakter sayısı (async için). Varsayılan: 1 */ + minChars?: number +} + +// ── Yardımcılar ───────────────────────────────────────────────────────────── + +function defaultFilter(options: AutoCompleteOption[], query: string) { + const lower = query.toLowerCase() + return options.filter( + (o) => !o.disabled && o.label.toLowerCase().includes(lower), + ) +} + +function useDebounce) => void>( + fn: T, + delay: number, +) { + const timerRef = useRef | null>(null) + return useCallback( + (...args: Parameters) => { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => fn(...args), delay) + }, + [fn, delay], + ) +} + +// ── Komponent ──────────────────────────────────────────────────────────────── + +const AutoComplete = forwardRef( + (props, ref) => { + const { + className, + style, + options = [], + value: valueProp, + defaultValue = '', + placeholder, + disabled = false, + invalid = false, + size, + fetchOptions, + debounce: debounceMs = 300, + renderOption, + noOptionsText = 'Seçenek bulunamadı', + loadingText = 'Yükleniyor...', + onInputChange, + onSelect, + clearable = true, + name, + field, + form, + minChars = 1, + ...rest + } = props + + const isControlled = valueProp !== undefined + const [inputValue, setInputValue] = useState( + field?.value ?? (isControlled ? valueProp! : defaultValue), + ) + const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(-1) + const [filteredOptions, setFilteredOptions] = useState< + AutoCompleteOption[] + >([]) + const [loading, setLoading] = useState(false) + + const containerRef = useRef(null) + const listRef = useRef(null) + const listboxId = useId() + + const { themeColor, primaryColorLevel, controlSize } = useConfig() + const formControlSize = useForm()?.size + const inputGroupSize = useInputGroup()?.size + const resolvedSize = + size || inputGroupSize || formControlSize || controlSize + const sizeClass = `h-${CONTROL_SIZES[resolvedSize]}` + + // Sync controlled value + useEffect(() => { + if (isControlled) setInputValue(valueProp!) + }, [isControlled, valueProp]) + + // Sync field value (Formik / RHF) + useEffect(() => { + if (field?.value !== undefined) setInputValue(field.value) + }, [field?.value]) + + // Dışarı tıkla → kapat + useEffect(() => { + const handler = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false) + setActiveIndex(-1) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, []) + + // Statik filtreleme + const filterStatic = useCallback( + (query: string) => { + if (!fetchOptions) { + const result = query ? defaultFilter(options, query) : options + setFilteredOptions(result) + } + }, + [fetchOptions, options], + ) + + // Async yükleme (debounced) + const fetchAsync = useDebounce(async (query: string) => { + if (!fetchOptions) return + setLoading(true) + try { + const result = await fetchOptions(query) + setFilteredOptions(result) + } finally { + setLoading(false) + } + }, debounceMs) + + const openDropdown = useCallback( + (query: string) => { + if (query.length < minChars && fetchOptions) return + setOpen(true) + setActiveIndex(-1) + if (fetchOptions) { + fetchAsync(query) + } else { + filterStatic(query) + } + }, + [minChars, fetchOptions, fetchAsync, filterStatic], + ) + + const handleChange = (e: ChangeEvent) => { + const val = e.target.value + if (!isControlled) setInputValue(val) + field?.onChange?.(val) + onInputChange?.(val) + openDropdown(val) + } + + const handleFocus = () => { + openDropdown(inputValue) + } + + const commitSelection = useCallback( + (option: AutoCompleteOption) => { + if (!isControlled) setInputValue(option.label) + field?.onChange?.(option.label) + onInputChange?.(option.label) + onSelect?.(option) + setOpen(false) + setActiveIndex(-1) + }, + [isControlled, field, onInputChange, onSelect], + ) + + const handleKeyDown = (e: KeyboardEvent) => { + if (!open) { + if (e.key === 'ArrowDown') openDropdown(inputValue) + return + } + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setActiveIndex((i) => + Math.min(i + 1, filteredOptions.length - 1), + ) + break + case 'ArrowUp': + e.preventDefault() + setActiveIndex((i) => Math.max(i - 1, -1)) + break + case 'Enter': + e.preventDefault() + if (activeIndex >= 0 && filteredOptions[activeIndex]) { + commitSelection(filteredOptions[activeIndex]) + } + break + case 'Escape': + setOpen(false) + setActiveIndex(-1) + break + case 'Tab': + setOpen(false) + break + } + } + + // Aktif öğeyi listede görünür yap + useEffect(() => { + if (activeIndex < 0 || !listRef.current) return + const item = listRef.current.children[activeIndex] as HTMLElement + item?.scrollIntoView?.({ block: 'nearest' }) + }, [activeIndex]) + + const handleClear = () => { + if (!isControlled) setInputValue('') + field?.onChange?.('') + onInputChange?.('') + setOpen(false) + setFilteredOptions([]) + } + + const ringClass = `focus:ring-${themeColor}-${primaryColorLevel}` + const invalidClass = 'border-red-500 focus:ring-red-500' + + return ( +
+
+ = 0 + ? `autocomplete-opt-${activeIndex}` + : undefined + } + onChange={handleChange} + onFocus={handleFocus} + onKeyDown={handleKeyDown} + {...rest} + /> +
+ {loading && ( + + )} + {clearable && inputValue && !loading && ( + + )} +
+
+ + {open && ( +
    + {loading ? ( +
  • + {loadingText} +
  • + ) : filteredOptions.length === 0 ? ( +
  • + {noOptionsText} +
  • + ) : ( + filteredOptions.map((option, index) => ( +
  • e.preventDefault()} + onClick={() => { + if (!option.disabled) + commitSelection(option) + }} + onMouseEnter={() => setActiveIndex(index)} + > + {renderOption + ? renderOption(option, activeIndex === index) + : option.label} +
  • + )) + )} +
+ )} +
+ ) + }, +) + +AutoComplete.displayName = 'AutoComplete' + +export default AutoComplete diff --git a/ui/src/components/ui/AutoComplete/index.tsx b/ui/src/components/ui/AutoComplete/index.tsx new file mode 100644 index 0000000..f1a2e20 --- /dev/null +++ b/ui/src/components/ui/AutoComplete/index.tsx @@ -0,0 +1,2 @@ +export { default } from './AutoComplete' +export type { AutoCompleteProps, AutoCompleteOption } from './AutoComplete' diff --git a/ui/src/components/ui/Breadcrumb/Breadcrumb.tsx b/ui/src/components/ui/Breadcrumb/Breadcrumb.tsx new file mode 100644 index 0000000..338065e --- /dev/null +++ b/ui/src/components/ui/Breadcrumb/Breadcrumb.tsx @@ -0,0 +1,179 @@ +import { + Children, + cloneElement, + forwardRef, + isValidElement, + type ReactNode, +} from 'react' +import classNames from 'classnames' +import type { CommonProps } from '../@types/common' + +export interface BreadcrumbItemProps extends CommonProps { + /** Tıklanabilir link href */ + href?: string + /** Özel link bileşeni (react-router Link vb.) */ + as?: React.ElementType + /** Aktif (son) öğe mi? Otomatik set edilir */ + active?: boolean + /** İkon (solda) */ + icon?: ReactNode + /** Tıklama eventi */ + onClick?: (e: React.MouseEvent) => void +} + +export interface BreadcrumbProps extends CommonProps { + /** Ayraç. Varsayılan: '/' */ + separator?: ReactNode + /** Öğeler arası boşluk. Varsayılan: 8 */ + gap?: number + /** Son öğe hariç metni maxWidth ile kırp */ + maxItems?: number + /** Küçük boyut */ + size?: 'sm' | 'md' | 'lg' +} + +export const BreadcrumbItem = forwardRef( + (props, ref) => { + const { + className, + children, + style, + href, + as: Component, + active = false, + icon, + onClick, + ...rest + } = props + + const Tag = Component ?? (href ? 'a' : 'span') + + return ( +
  • + + {icon && ( + {icon} + )} + {children} + +
  • + ) + }, +) + +BreadcrumbItem.displayName = 'BreadcrumbItem' + +const Breadcrumb = forwardRef((props, ref) => { + const { + className, + children, + style, + separator = '/', + gap = 8, + maxItems, + size = 'md', + ...rest + } = props + + const items = Children.toArray(children).filter(isValidElement) + const total = items.length + + let visibleItems = items + let collapsedCount = 0 + + if (maxItems && total > maxItems) { + collapsedCount = total - maxItems + visibleItems = [ + items[0], + ...items.slice(total - (maxItems - 1)), + ] + } + + const renderedItems = visibleItems.map((item, index) => { + const isLast = + maxItems && collapsedCount > 0 + ? index === visibleItems.length - 1 + : index === total - 1 + + const cloned = cloneElement(item as React.ReactElement, { + active: isLast, + }) + + return ( + + {index === 1 && collapsedCount > 0 && ( + <> + + {separator} + + + ... + + + )} + {index > 0 && ( + + {separator} + + )} + {cloned} + + ) + }) + + return ( + + ) +}) + +Breadcrumb.displayName = 'Breadcrumb' + +const BreadcrumbWithItem = Breadcrumb as typeof Breadcrumb & { + Item: typeof BreadcrumbItem +} +BreadcrumbWithItem.Item = BreadcrumbItem + +export default BreadcrumbWithItem diff --git a/ui/src/components/ui/Breadcrumb/index.tsx b/ui/src/components/ui/Breadcrumb/index.tsx new file mode 100644 index 0000000..fcde9b2 --- /dev/null +++ b/ui/src/components/ui/Breadcrumb/index.tsx @@ -0,0 +1,2 @@ +export { default } from './Breadcrumb' +export type { BreadcrumbProps, BreadcrumbItemProps } from './Breadcrumb' diff --git a/ui/src/components/ui/Chips/Chips.tsx b/ui/src/components/ui/Chips/Chips.tsx new file mode 100644 index 0000000..2e8ca90 --- /dev/null +++ b/ui/src/components/ui/Chips/Chips.tsx @@ -0,0 +1,254 @@ +import { + forwardRef, + useState, + useRef, + useCallback, + useEffect, + type KeyboardEvent, + type ReactNode, +} from 'react' +import classNames from 'classnames' +import { useConfig } from '../ConfigProvider' +import { useForm } from '../Form/context' +import { useInputGroup } from '../InputGroup/context' +import { CONTROL_SIZES } from '../utils/constants' +import type { CommonProps, TypeAttributes } from '../@types/common' + +export interface ChipsProps extends CommonProps { + /** Kontrollü değer */ + value?: string[] + /** Başlangıç değeri (kontrolsüz) */ + defaultValue?: string[] + /** Placeholder */ + placeholder?: string + /** Devre dışı */ + disabled?: boolean + /** Geçersiz */ + invalid?: boolean + /** Boyut */ + size?: TypeAttributes.ControlSize + /** Maks chip sayısı. 0 = sınırsız */ + max?: number + /** Çift değere izin verme. Varsayılan: false */ + allowDuplicate?: boolean + /** Enter'a ek ayraç tuşu. Örn: ',' */ + separator?: string + /** Chip özelleştirme render */ + itemTemplate?: (value: string) => ReactNode + /** Değer değiştiğinde */ + onChange?: (value: string[]) => void + /** Chip eklendiğinde */ + onAdd?: (value: string) => void + /** Chip kaldırıldığında */ + onRemove?: (value: string) => void + /** Input adı */ + name?: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + field?: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + form?: any +} + +const Chips = forwardRef((props, ref) => { + const { + className, + style, + value: valueProp, + defaultValue = [], + placeholder, + disabled = false, + invalid = false, + size, + max = 0, + allowDuplicate = false, + separator, + itemTemplate, + onChange, + onAdd, + onRemove, + name, + field, + form, + ...rest + } = props + + const isControlled = valueProp !== undefined + const [chips, setChips] = useState( + field?.value ?? (isControlled ? valueProp! : defaultValue), + ) + const [inputVal, setInputVal] = useState('') + const [focusedChip, setFocusedChip] = useState(null) + const inputRef = useRef(null) + + const { themeColor, primaryColorLevel, controlSize } = useConfig() + const formControlSize = useForm()?.size + const inputGroupSize = useInputGroup()?.size + const resolvedSize = size || inputGroupSize || formControlSize || controlSize + const minH = `min-h-${CONTROL_SIZES[resolvedSize]}` + + useEffect(() => { + if (isControlled) setChips(valueProp!) + }, [isControlled, valueProp]) + + useEffect(() => { + if (field?.value !== undefined) setChips(field.value) + }, [field?.value]) + + const commit = useCallback( + (next: string[]) => { + if (!isControlled) setChips(next) + field?.onChange?.(next) + onChange?.(next) + }, + [isControlled, field, onChange], + ) + + const addChip = useCallback( + (raw: string) => { + const val = raw.trim() + if (!val) return + if (!allowDuplicate && chips.includes(val)) return + if (max > 0 && chips.length >= max) return + const next = [...chips, val] + commit(next) + onAdd?.(val) + setInputVal('') + }, + [chips, allowDuplicate, max, commit, onAdd], + ) + + const removeChip = useCallback( + (index: number) => { + const removed = chips[index] + const next = chips.filter((_, i) => i !== index) + commit(next) + onRemove?.(removed) + setFocusedChip(null) + inputRef.current?.focus() + }, + [chips, commit, onRemove], + ) + + const handleKeyDown = (e: KeyboardEvent) => { + const val = inputVal + + if (e.key === 'Enter') { + e.preventDefault() + addChip(val) + return + } + + if (separator && e.key === separator) { + e.preventDefault() + addChip(val) + return + } + + if (e.key === 'Backspace' && !val && chips.length > 0) { + removeChip(chips.length - 1) + return + } + + if (e.key === 'ArrowLeft' && !val) { + setFocusedChip(chips.length - 1) + return + } + } + + const handleChipKeyDown = (e: KeyboardEvent, index: number) => { + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault() + removeChip(index) + return + } + if (e.key === 'ArrowLeft') { + e.preventDefault() + setFocusedChip(Math.max(0, index - 1)) + return + } + if (e.key === 'ArrowRight') { + e.preventDefault() + if (index === chips.length - 1) { + setFocusedChip(null) + inputRef.current?.focus() + } else { + setFocusedChip(index + 1) + } + } + } + + const isMaxReached = max > 0 && chips.length >= max + const ringClass = `focus-within:ring-${themeColor}-${primaryColorLevel}` + + return ( +
    inputRef.current?.focus()} + {...rest} + > + {chips.map((chip, i) => ( + setFocusedChip(i)} + onBlur={() => setFocusedChip(null)} + onKeyDown={(e) => handleChipKeyDown(e, i)} + data-focused={focusedChip === i} + > + + {itemTemplate ? itemTemplate(chip) : chip} + + {!disabled && ( + + )} + + ))} + + {!isMaxReached && !disabled && ( + setInputVal(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (inputVal.trim()) addChip(inputVal) + }} + /> + )} +
    + ) +}) + +Chips.displayName = 'Chips' + +export default Chips diff --git a/ui/src/components/ui/Chips/index.tsx b/ui/src/components/ui/Chips/index.tsx new file mode 100644 index 0000000..3c2676f --- /dev/null +++ b/ui/src/components/ui/Chips/index.tsx @@ -0,0 +1,2 @@ +export { default } from './Chips' +export type { ChipsProps } from './Chips' diff --git a/ui/src/components/ui/ColorPicker/ColorPicker.tsx b/ui/src/components/ui/ColorPicker/ColorPicker.tsx new file mode 100644 index 0000000..eada06f --- /dev/null +++ b/ui/src/components/ui/ColorPicker/ColorPicker.tsx @@ -0,0 +1,309 @@ +import { + forwardRef, + useState, + useRef, + useCallback, + useEffect, + type ChangeEvent, +} from 'react' +import classNames from 'classnames' +import type { CommonProps, TypeAttributes } from '../@types/common' + +// ── Hex/RGB yardımcı fonksiyonlar ─────────────────────────────────────────── + +const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => { + const cleaned = hex.replace('#', '') + if (cleaned.length !== 6 && cleaned.length !== 3) return null + const full = + cleaned.length === 3 + ? cleaned + .split('') + .map((c) => c + c) + .join('') + : cleaned + const num = parseInt(full, 16) + return { + r: (num >> 16) & 255, + g: (num >> 8) & 255, + b: num & 255, + } +} + +const rgbToHex = (r: number, g: number, b: number): string => { + return ( + '#' + + [r, g, b] + .map((v) => Math.min(255, Math.max(0, v)).toString(16).padStart(2, '0')) + .join('') + ) +} + +const isValidHex = (val: string) => /^#[0-9a-fA-F]{6}$/.test(val) + +// ── Tipler ────────────────────────────────────────────────────────────────── + +export interface ColorPickerProps extends CommonProps { + /** Kontrollü değer (hex: '#rrggbb') */ + value?: string + /** Başlangıç değeri (kontrolsüz) */ + defaultValue?: string + /** Devre dışı */ + disabled?: boolean + /** Boyut */ + size?: TypeAttributes.ControlSize + /** Önceden tanımlı renk paleti */ + presets?: string[] + /** Alpha (opacity) kanalı göster */ + showAlpha?: boolean + /** Hex input göster. Varsayılan: true */ + showInput?: boolean + /** RGB değerlerini ayrı göster */ + showRgb?: boolean + /** Değer değiştiğinde callback */ + onChange?: (hex: string) => void + /** Input adı (form entegrasyonu) */ + name?: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + field?: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + form?: any +} + +// ── Bileşen ───────────────────────────────────────────────────────────────── + +const ColorPicker = forwardRef((props, ref) => { + const { + className, + style, + value: valueProp, + defaultValue = '#6366f1', + disabled = false, + size = 'md', + presets, + showInput = true, + showRgb = false, + onChange, + name, + field, + form: _form, + ...rest + } = props + + const isControlled = valueProp !== undefined + const fieldValue = field?.value + + const resolveInitial = () => { + if (fieldValue && isValidHex(fieldValue)) return fieldValue + if (isControlled && valueProp && isValidHex(valueProp)) return valueProp! + if (isValidHex(defaultValue)) return defaultValue + return '#6366f1' + } + + const [internalHex, setInternalHex] = useState(resolveInitial) + const [inputText, setInputText] = useState(resolveInitial) + const [open, setOpen] = useState(false) + const containerRef = useRef(null) + const nativeRef = useRef(null) + + const currentHex = (() => { + const src = isControlled ? valueProp! : fieldValue ?? internalHex + return isValidHex(src) ? src : internalHex + })() + + // sync input text ile dış değer + useEffect(() => { + setInputText(currentHex) + }, [currentHex]) + + // Dışarı tıklayınca kapat + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + const commit = useCallback( + (hex: string) => { + if (!isControlled) setInternalHex(hex) + field?.onChange?.(hex) + onChange?.(hex) + }, + [isControlled, onChange, field], + ) + + const handleNativeChange = (e: ChangeEvent) => { + const hex = e.target.value + setInputText(hex) + commit(hex) + } + + const handleInputChange = (e: ChangeEvent) => { + const raw = e.target.value + setInputText(raw) + const normalized = raw.startsWith('#') ? raw : `#${raw}` + if (isValidHex(normalized)) { + commit(normalized) + } + } + + const handleInputBlur = () => { + const normalized = inputText.startsWith('#') ? inputText : `#${inputText}` + if (!isValidHex(normalized)) { + setInputText(currentHex) + } + } + + const handlePresetClick = (color: string) => { + setInputText(color) + commit(color) + } + + const rgb = hexToRgb(currentHex) + + const handleRgbChange = (channel: 'r' | 'g' | 'b', val: string) => { + if (!rgb) return + const num = Math.min(255, Math.max(0, parseInt(val) || 0)) + const updated = { ...rgb, [channel]: num } + const hex = rgbToHex(updated.r, updated.g, updated.b) + setInputText(hex) + commit(hex) + } + + const sizeClass = { + lg: 'color-picker-lg', + md: 'color-picker-md', + sm: 'color-picker-sm', + xs: 'color-picker-xs', + }[size] + + return ( +
    { + ;( + containerRef as React.MutableRefObject + ).current = node + if (typeof ref === 'function') ref(node) + else if (ref) ref.current = node + }} + className={classNames('color-picker', sizeClass, className)} + style={style} + {...rest} + > + {/* Hidden native color input (asıl değer kaynağı) */} + + + {/* Tetikleyici swatch */} +
    + )} + + {/* Hex input */} + {showInput && ( +
    + HEX + +
    + )} + + {/* RGB inputs */} + {showRgb && rgb && ( +
    + {(['r', 'g', 'b'] as const).map((ch) => ( +
    + + handleRgbChange(ch, e.target.value) + } + /> + + {ch.toUpperCase()} + +
    + ))} +
    + )} + + )} + + ) +}) + +ColorPicker.displayName = 'ColorPicker' + +export default ColorPicker diff --git a/ui/src/components/ui/ColorPicker/index.tsx b/ui/src/components/ui/ColorPicker/index.tsx new file mode 100644 index 0000000..32c7d5c --- /dev/null +++ b/ui/src/components/ui/ColorPicker/index.tsx @@ -0,0 +1,2 @@ +export { default } from './ColorPicker' +export type { ColorPickerProps } from './ColorPicker' diff --git a/ui/src/components/ui/ImageViewer/ImageViewer.tsx b/ui/src/components/ui/ImageViewer/ImageViewer.tsx new file mode 100644 index 0000000..4abf3cb --- /dev/null +++ b/ui/src/components/ui/ImageViewer/ImageViewer.tsx @@ -0,0 +1,502 @@ +import { + forwardRef, + useState, + useCallback, + useEffect, + useRef, + type ReactNode, + type MouseEvent, + type WheelEvent, +} from 'react' +import { createPortal } from 'react-dom' +import classNames from 'classnames' +import type { CommonProps } from '../@types/common' + +// ── Tipler ────────────────────────────────────────────────────────────────── + +export interface ImageViewerImage { + src: string + alt?: string + caption?: string + thumbnail?: string +} + +export interface ImageViewerProps extends CommonProps { + /** Görüntülenecek resimler */ + images: ImageViewerImage[] + /** Açılış resmi index'i (kontrolsüz) */ + defaultIndex?: number + /** Kontrollü açık/kapalı durumu */ + open?: boolean + /** Kontrollü aktif index */ + activeIndex?: number + /** Kapatma isteği */ + onClose?: () => void + /** Index değişimi */ + onIndexChange?: (index: number) => void + /** Döngü. Varsayılan: true */ + loop?: boolean + /** Toolbar'ı göster. Varsayılan: true */ + showToolbar?: boolean + /** Thumbnailleri göster. Varsayılan: true */ + showThumbnails?: boolean + /** Özel toolbar başlık sağ alanı */ + toolbarExtra?: ReactNode + /** Klavye navigasyonu. Varsayılan: true */ + keyboard?: boolean + /** Zoom adımı. Varsayılan: 0.25 */ + zoomStep?: number + /** Min zoom. Varsayılan: 0.5 */ + minZoom?: number + /** Max zoom. Varsayılan: 4 */ + maxZoom?: number +} + +// ── İkonlar ────────────────────────────────────────────────────────────────── + +const IconClose = () => ( + + + + +) +const IconPrev = () => ( + + + +) +const IconNext = () => ( + + + +) +const IconZoomIn = () => ( + + + + + + +) +const IconZoomOut = () => ( + + + + + +) +const IconReset = () => ( + + + + +) +const IconRotateCW = () => ( + + + + +) +const IconRotateCCW = () => ( + + + + +) +const IconDownload = () => ( + + + + + +) + +// ── Overlay ────────────────────────────────────────────────────────────────── + +const ImageViewerOverlay = ({ + images, + index, + loop, + showToolbar, + showThumbnails, + toolbarExtra, + zoomStep, + minZoom, + maxZoom, + onClose, + onIndexChange, +}: { + images: ImageViewerImage[] + index: number + loop: boolean + showToolbar: boolean + showThumbnails: boolean + toolbarExtra?: ReactNode + zoomStep: number + minZoom: number + maxZoom: number + onClose: () => void + onIndexChange: (i: number) => void +}) => { + const [zoom, setZoom] = useState(1) + const [rotation, setRotation] = useState(0) + const [dragging, setDragging] = useState(false) + const [offset, setOffset] = useState({ x: 0, y: 0 }) + const dragStart = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null) + const imgRef = useRef(null) + + const current = images[index] + const hasPrev = loop ? images.length > 1 : index > 0 + const hasNext = loop ? images.length > 1 : index < images.length - 1 + + const resetTransform = useCallback(() => { + setZoom(1) + setRotation(0) + setOffset({ x: 0, y: 0 }) + }, []) + + // Reset on image change + useEffect(() => { resetTransform() }, [index, resetTransform]) + + const goNext = useCallback(() => { + if (!hasNext) return + onIndexChange(loop ? (index + 1) % images.length : index + 1) + }, [hasNext, loop, index, images.length, onIndexChange]) + + const goPrev = useCallback(() => { + if (!hasPrev) return + onIndexChange(loop ? (index - 1 + images.length) % images.length : index - 1) + }, [hasPrev, loop, index, images.length, onIndexChange]) + + const zoomIn = useCallback(() => + setZoom((z) => Math.min(z + zoomStep, maxZoom)), [zoomStep, maxZoom]) + const zoomOut = useCallback(() => + setZoom((z) => Math.max(z - zoomStep, minZoom)), [zoomStep, minZoom]) + const rotateCW = useCallback(() => setRotation((r) => r + 90), []) + const rotateCCW = useCallback(() => setRotation((r) => r - 90), []) + + const handleDownload = useCallback(() => { + const src = current.src + const isDataUri = src.startsWith('data:') + + if (isDataUri) { + // data:[];base64, veya data:[], + const mimeMatch = src.match(/^data:([^;,]+)/) + const mime = mimeMatch?.[1] ?? 'image/png' + const ext = mime.split('/')[1]?.replace('jpeg', 'jpg') ?? 'png' + const baseName = current.alt + ? current.alt.replace(/\.[^.]+$/, '') + : 'image' + const a = document.createElement('a') + a.href = src + a.download = `${baseName}.${ext}` + a.click() + } else { + // Normal URL - dosya adını URL'den çıkar, yoksa alt veya 'image' kullan + let fileName = current.alt ?? '' + if (!fileName) { + try { + const url = new URL(src, window.location.href) + const pathParts = url.pathname.split('/') + fileName = pathParts[pathParts.length - 1] || 'image' + } catch { + fileName = 'image' + } + } + const a = document.createElement('a') + a.href = src + a.download = fileName + // Aynı origin değilse fetch + blob ile zorla indir + try { + const isSameOrigin = + new URL(src, window.location.href).origin === window.location.origin + if (!isSameOrigin) { + fetch(src) + .then((r) => r.blob()) + .then((blob) => { + const blobUrl = URL.createObjectURL(blob) + a.href = blobUrl + a.click() + URL.revokeObjectURL(blobUrl) + }) + return + } + } catch { /* origin parse hatası — direkt dene */ } + a.click() + } + }, [current]) + + // Keyboard + useEffect(() => { + const handler = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowLeft': goPrev(); break + case 'ArrowRight': goNext(); break + case 'Escape': onClose(); break + case '+': case '=': zoomIn(); break + case '-': zoomOut(); break + case 'r': rotateCW(); break + case 'R': rotateCCW(); break + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [goPrev, goNext, onClose, zoomIn, zoomOut, rotateCW, rotateCCW]) + + // Wheel zoom + const handleWheel = (e: WheelEvent) => { + e.preventDefault() + if (e.deltaY < 0) zoomIn() + else zoomOut() + } + + // Drag (pan) + const handleMouseDown = (e: MouseEvent) => { + if (zoom <= 1) return + e.preventDefault() + setDragging(true) + dragStart.current = { x: e.clientX, y: e.clientY, ox: offset.x, oy: offset.y } + } + const handleMouseMove = (e: MouseEvent) => { + if (!dragging || !dragStart.current) return + setOffset({ + x: dragStart.current.ox + (e.clientX - dragStart.current.x), + y: dragStart.current.oy + (e.clientY - dragStart.current.y), + }) + } + const handleMouseUp = () => { setDragging(false); dragStart.current = null } + + const overlayClick = (e: MouseEvent) => { + if (e.target === e.currentTarget) onClose() + } + + return ( +
    + {/* Toolbar */} + {showToolbar && ( +
    + + {index + 1} / {images.length} + + {current.caption && ( + {current.caption} + )} +
    + {toolbarExtra} + + + {Math.round(zoom * 100)}% + + + + + + + +
    +
    + )} + + {/* Stage */} +
    + {current.alt 1 ? (dragging ? 'grabbing' : 'grab') : 'default', + }} + /> +
    + + {/* Prev / Next */} + {hasPrev && ( + + )} + {hasNext && ( + + )} + + {/* Thumbnails */} + {showThumbnails && images.length > 1 && ( +
    + {images.map((img, i) => ( + + ))} +
    + )} +
    + ) +} + +// ── Ana Komponent ──────────────────────────────────────────────────────────── + +const ImageViewer = forwardRef((props, ref) => { + const { + className, + style, + images, + defaultIndex = 0, + open: openProp, + activeIndex: activeIndexProp, + onClose, + onIndexChange, + loop = true, + showToolbar = true, + showThumbnails = true, + toolbarExtra, + keyboard: _keyboard = true, + zoomStep = 0.25, + minZoom = 0.5, + maxZoom = 4, + children, + ...rest + } = props + + const isControlled = openProp !== undefined + const [internalOpen, setInternalOpen] = useState(false) + const [internalIndex, setInternalIndex] = useState(defaultIndex) + + const isOpen = isControlled ? openProp! : internalOpen + const currentIndex = + activeIndexProp !== undefined ? activeIndexProp : internalIndex + + const handleClose = useCallback(() => { + if (!isControlled) setInternalOpen(false) + onClose?.() + }, [isControlled, onClose]) + + const handleIndexChange = useCallback( + (i: number) => { + if (activeIndexProp === undefined) setInternalIndex(i) + onIndexChange?.(i) + }, + [activeIndexProp, onIndexChange], + ) + + const openAt = useCallback( + (index: number) => { + setInternalIndex(index) + if (!isControlled) setInternalOpen(true) + }, + [isControlled], + ) + + if (!images || images.length === 0) return null + + return ( + <> + {/* Trigger wrapper (children varsa tıklanabilir galeri) */} + {children && ( +
    + {images.map((img, i) => ( + + ))} +
    + )} + + {/* Portal overlay */} + {isOpen && + createPortal( + , + document.body, + )} + + ) +}) + +ImageViewer.displayName = 'ImageViewer' + +export default ImageViewer diff --git a/ui/src/components/ui/ImageViewer/index.tsx b/ui/src/components/ui/ImageViewer/index.tsx new file mode 100644 index 0000000..9d4c6c7 --- /dev/null +++ b/ui/src/components/ui/ImageViewer/index.tsx @@ -0,0 +1,2 @@ +export { default } from './ImageViewer' +export type { ImageViewerProps, ImageViewerImage } from './ImageViewer' diff --git a/ui/src/components/ui/Knob/Knob.tsx b/ui/src/components/ui/Knob/Knob.tsx new file mode 100644 index 0000000..0884f06 --- /dev/null +++ b/ui/src/components/ui/Knob/Knob.tsx @@ -0,0 +1,329 @@ +import { + forwardRef, + useState, + useRef, + useCallback, + useEffect, + type KeyboardEvent, + type PointerEvent as ReactPointerEvent, +} from 'react' +import classNames from 'classnames' +import { useConfig } from '../ConfigProvider' +import type { CommonProps } from '../@types/common' + +export interface KnobProps extends CommonProps { + /** Kontrollü değer */ + value?: number + /** Başlangıç değeri (kontrolsüz) */ + defaultValue?: number + /** Minimum değer. Varsayılan: 0 */ + min?: number + /** Maksimum değer. Varsayılan: 100 */ + max?: number + /** Adım büyüklüğü. Varsayılan: 1 */ + step?: number + /** Çap (px). Varsayılan: 100 */ + size?: number + /** Çizgi kalınlığı (px). Varsayılan: 14 */ + strokeWidth?: number + /** Değer yayının rengi. Varsayılan: tema rengi */ + valueColor?: string + /** Arka plan yayının rengi */ + rangeColor?: string + /** Etiket metni rengi */ + textColor?: string + /** Değer şablonu. '{value}' placeholder'ı. Varsayılan: '{value}' */ + valueTemplate?: string + /** Salt okunur */ + readOnly?: boolean + /** Devre dışı */ + disabled?: boolean + /** Etiket göster. Varsayılan: true */ + showValue?: boolean + /** Değer değiştiğinde */ + onChange?: (value: number) => void + /** Input adı (hidden) */ + name?: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + field?: any +} + +const KNOB_START_ANGLE = -220 // derece (saat 7 hizası) +const KNOB_END_ANGLE = 40 // derece (saat 5 hizası) +const KNOB_RANGE = KNOB_END_ANGLE - KNOB_START_ANGLE // 260 derece toplam yay + +const clamp = (v: number, min: number, max: number) => + Math.min(max, Math.max(min, v)) + +const snap = (v: number, step: number, min: number) => + Math.round((v - min) / step) * step + min + +const Knob = forwardRef((props, ref) => { + const { + className, + style, + value: valueProp, + defaultValue = 0, + min = 0, + max = 100, + step = 1, + size = 100, + strokeWidth = 14, + valueColor, + rangeColor, + textColor, + valueTemplate = '{value}', + readOnly = false, + disabled = false, + showValue = true, + onChange, + name, + field, + ...rest + } = props + + const { themeColor, primaryColorLevel } = useConfig() + const isControlled = valueProp !== undefined + const [internalValue, setInternalValue] = useState( + clamp(field?.value ?? (isControlled ? valueProp! : defaultValue), min, max), + ) + + useEffect(() => { + if (isControlled) setInternalValue(clamp(valueProp!, min, max)) + }, [isControlled, valueProp, min, max]) + + useEffect(() => { + if (field?.value !== undefined) + setInternalValue(clamp(field.value, min, max)) + }, [field?.value, min, max]) + + const currentValue = isControlled + ? clamp(valueProp!, min, max) + : internalValue + + const commit = useCallback( + (raw: number) => { + const snapped = clamp(snap(raw, step, min), min, max) + if (!isControlled) setInternalValue(snapped) + field?.onChange?.(snapped) + onChange?.(snapped) + }, + [isControlled, step, min, max, field, onChange], + ) + + // SVG geometri + const r = (size - strokeWidth) / 2 + const cx = size / 2 + const cy = size / 2 + const circumference = 2 * Math.PI * r + + const valueRatio = (currentValue - min) / (max - min) + const valueAngleDeg = KNOB_START_ANGLE + valueRatio * KNOB_RANGE + const valueArcLength = (valueRatio * KNOB_RANGE / 360) * circumference + const fullArcLength = (KNOB_RANGE / 360) * circumference + + // Çember yayını SVG stroke-dashoffset ile çiziyoruz + // SVG default: 0 derece = sağ (3 saat), CW pozitif + // Bizim 0 = üst → rotate(-90) + açı offset + const startAngleRad = ((KNOB_START_ANGLE - 90) * Math.PI) / 180 + + const resolveValueColor = () => + valueColor ?? `var(--color-${themeColor}-${primaryColorLevel}, #6366f1)` + const resolveRangeColor = () => + rangeColor ?? `currentColor` + const resolveTextColor = () => textColor ?? undefined + + const label = valueTemplate.replace('{value}', String(currentValue)) + + // Pointer/drag etkileşimi + const svgRef = useRef(null) + const dragging = useRef(false) + + const angleToValue = useCallback( + (angleDeg: number) => { + // Normalize to [KNOB_START_ANGLE, KNOB_END_ANGLE] + let a = angleDeg - KNOB_START_ANGLE + if (a < 0) a = 0 + if (a > KNOB_RANGE) a = KNOB_RANGE + return min + (a / KNOB_RANGE) * (max - min) + }, + [min, max], + ) + + const getAngleFromPointer = useCallback( + (clientX: number, clientY: number) => { + const el = svgRef.current + if (!el) return 0 + const rect = el.getBoundingClientRect() + const centerX = rect.left + rect.width / 2 + const centerY = rect.top + rect.height / 2 + const dx = clientX - centerX + const dy = clientY - centerY + // atan2 → derece, SVG 0=sağ → 90 ekle için 90 çıkarıyoruz + let angle = (Math.atan2(dy, dx) * 180) / Math.PI + 90 + // Normalize: KNOB_START_ANGLE -220 → üst-sol + if (angle < KNOB_START_ANGLE + 360) { + // Döngü düzeltmesi + } + return angle + }, + [], + ) + + const handlePointerDown = (e: ReactPointerEvent) => { + if (disabled || readOnly) return + e.currentTarget.setPointerCapture(e.pointerId) + dragging.current = true + const angle = getAngleFromPointer(e.clientX, e.clientY) + commit(angleToValue(angle)) + } + + const handlePointerMove = (e: ReactPointerEvent) => { + if (!dragging.current || disabled || readOnly) return + const angle = getAngleFromPointer(e.clientX, e.clientY) + commit(angleToValue(angle)) + } + + const handlePointerUp = () => { + dragging.current = false + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (disabled || readOnly) return + switch (e.key) { + case 'ArrowRight': + case 'ArrowUp': + e.preventDefault() + commit(currentValue + step) + break + case 'ArrowLeft': + case 'ArrowDown': + e.preventDefault() + commit(currentValue - step) + break + case 'Home': + e.preventDefault() + commit(min) + break + case 'End': + e.preventDefault() + commit(max) + break + case 'PageUp': + e.preventDefault() + commit(currentValue + step * 10) + break + case 'PageDown': + e.preventDefault() + commit(currentValue - step * 10) + break + } + } + + // Stroke-dasharray/offset hesaplama + // Toplam çevre üzerinde KNOB_RANGE kadar gösteriyoruz + const dashArray = `${fullArcLength} ${circumference}` + const valueDash = `${valueArcLength} ${circumference}` + + // Rotation: SVG'de 0 derece sağ → başlangıç açısını ayarlamak için rotate uygulayacağız + const trackRotation = `rotate(${KNOB_START_ANGLE + 90}, ${cx}, ${cy})` + + return ( + + {name && ( + + )} + { + ;(svgRef as React.MutableRefObject).current = node + if (typeof ref === 'function') ref(node) + else if (ref) ref.current = node + }} + width={size} + height={size} + viewBox={`0 0 ${size} ${size}`} + className={classNames( + 'knob', + !disabled && !readOnly && 'knob-interactive', + disabled && 'knob-disabled', + )} + role="slider" + aria-valuemin={min} + aria-valuemax={max} + aria-valuenow={currentValue} + aria-valuetext={label} + aria-disabled={disabled} + aria-readonly={readOnly} + tabIndex={disabled || readOnly ? -1 : 0} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onKeyDown={handleKeyDown} + {...rest} + > + {/* Arka plan yayı */} + + {/* Değer yayı */} + {valueArcLength > 0 && ( + + )} + {/* Handle noktası */} + {(() => { + const handleAngle = (valueAngleDeg - 90) * (Math.PI / 180) + const hx = cx + r * Math.cos(handleAngle) + const hy = cy + r * Math.sin(handleAngle) + return ( + + ) + })()} + {/* Etiket */} + {showValue && ( + + {label} + + )} + + + ) +}) + +Knob.displayName = 'Knob' + +export default Knob diff --git a/ui/src/components/ui/Knob/index.tsx b/ui/src/components/ui/Knob/index.tsx new file mode 100644 index 0000000..487dde7 --- /dev/null +++ b/ui/src/components/ui/Knob/index.tsx @@ -0,0 +1,2 @@ +export { default } from './Knob' +export type { KnobProps } from './Knob' diff --git a/ui/src/components/ui/Marquee/Marquee.tsx b/ui/src/components/ui/Marquee/Marquee.tsx new file mode 100644 index 0000000..e858db1 --- /dev/null +++ b/ui/src/components/ui/Marquee/Marquee.tsx @@ -0,0 +1,221 @@ +import { + forwardRef, + useRef, + useState, + useEffect, + useCallback, + Fragment, +} from 'react' +import classNames from 'classnames' +import type { CommonProps } from '../@types/common' +import type { CSSProperties } from 'react' + +export interface MarqueeProps extends CommonProps { + /** Oynatma hızı (piksel/saniye). Varsayılan: 50 */ + speed?: number + /** Kaydırma yönü. Varsayılan: 'left' */ + direction?: 'left' | 'right' | 'up' | 'down' + /** Fare üzerindeyken duraklat. Varsayılan: false */ + pauseOnHover?: boolean + /** Tıklandığında duraklat. Varsayılan: false */ + pauseOnClick?: boolean + /** Döngü sayısı. 0 = sonsuz. Varsayılan: 0 */ + loop?: number + /** Kenar degradesi göster. Varsayılan: true */ + gradient?: boolean + /** Degrade rengi (rgb). Varsayılan: '255,255,255' */ + gradientColor?: string + /** Degrade genişliği. Varsayılan: 200 */ + gradientWidth?: number | string + /** Başlangıç gecikmesi (ms). Varsayılan: 0 */ + delay?: number + /** Oynatma durumu. Varsayılan: true */ + play?: boolean + /** İçeriği otomatik doldur. Varsayılan: false */ + autoFill?: boolean + /** Animasyon tamamlandığında callback */ + onFinish?: () => void + /** Döngü tamamlandığında callback */ + onCycleComplete?: () => void + /** Oynatma başladığında callback */ + onMount?: () => void +} + +const Marquee = forwardRef((props, ref) => { + const { + className, + children, + style, + speed = 50, + direction = 'left', + pauseOnHover = false, + pauseOnClick = false, + loop = 0, + gradient = true, + gradientColor = '255,255,255', + gradientWidth = 200, + delay = 0, + play = true, + autoFill = false, + onFinish, + onCycleComplete, + onMount, + ...rest + } = props + + const containerRef = useRef(null) + const trackRef = useRef(null) + const [trackWidth, setTrackWidth] = useState(0) + const [containerWidth, setContainerWidth] = useState(0) + const [multiplier, setMultiplier] = useState(1) + const [isMounted, setIsMounted] = useState(false) + + const isVertical = direction === 'up' || direction === 'down' + const isReverse = direction === 'right' || direction === 'down' + + const calculateWidth = useCallback(() => { + if (trackRef.current && containerRef.current) { + const tw = isVertical + ? trackRef.current.offsetHeight + : trackRef.current.offsetWidth + const cw = isVertical + ? containerRef.current.offsetHeight + : containerRef.current.offsetWidth + + if (autoFill && tw > 0) { + setMultiplier(Math.max(Math.ceil((cw * 2) / tw), 2)) + } else { + setMultiplier(1) + } + + setTrackWidth(tw) + setContainerWidth(cw) + } + }, [autoFill, isVertical]) + + useEffect(() => { + calculateWidth() + const resizeObserver = new ResizeObserver(calculateWidth) + if (containerRef.current) resizeObserver.observe(containerRef.current) + if (trackRef.current) resizeObserver.observe(trackRef.current) + return () => resizeObserver.disconnect() + }, [calculateWidth, children]) + + useEffect(() => { + setIsMounted(true) + onMount?.() + }, [onMount]) + + const duration = + trackWidth > 0 + ? autoFill + ? (trackWidth * multiplier) / speed + : Math.max(trackWidth, containerWidth) / speed + : 0 + + const gradientStyle: CSSProperties = gradient + ? { + ['--marquee-gradient-color' as string]: `rgba(${gradientColor}, 1), rgba(${gradientColor}, 0)`, + } + : {} + + const containerStyle: CSSProperties = { + ...gradientStyle, + ...style, + ['--marquee-gradient-width' as string]: + typeof gradientWidth === 'number' + ? `${gradientWidth}px` + : gradientWidth, + } + + const animationStyle: CSSProperties = { + ['--marquee-duration' as string]: `${duration}s`, + ['--marquee-delay' as string]: `${delay}s`, + ['--marquee-iteration-count' as string]: loop > 0 ? `${loop}` : 'infinite', + } + + const animationClass = isVertical + ? isReverse + ? 'marquee-animate-down' + : 'marquee-animate-up' + : isReverse + ? 'marquee-animate-right' + : 'marquee-animate-left' + + const isPaused = !play + + const handleAnimationIteration = () => { + onCycleComplete?.() + } + + const handleAnimationEnd = () => { + onFinish?.() + } + + const clonedItems = Array.from({ length: multiplier }, (_, i) => ( + {children} + )) + + return ( +
    { + ;(containerRef as React.MutableRefObject).current = + node + if (typeof ref === 'function') { + ref(node) + } else if (ref) { + ref.current = node + } + }} + className={classNames( + 'marquee-container', + isVertical && 'marquee-vertical', + gradient && 'marquee-gradient', + className, + )} + style={containerStyle} + {...rest} + > + {isMounted && ( + <> +
    1} + > + {clonedItems} +
    + {/* Klonlanmış track - kesintisiz görünüm için */} +
    + {clonedItems} +
    + + )} +
    + ) +}) + +Marquee.displayName = 'Marquee' + +export default Marquee diff --git a/ui/src/components/ui/Marquee/index.tsx b/ui/src/components/ui/Marquee/index.tsx new file mode 100644 index 0000000..776f672 --- /dev/null +++ b/ui/src/components/ui/Marquee/index.tsx @@ -0,0 +1,2 @@ +export { default } from './Marquee' +export type { MarqueeProps } from './Marquee' diff --git a/ui/src/components/ui/Rate/Rate.tsx b/ui/src/components/ui/Rate/Rate.tsx new file mode 100644 index 0000000..0776fd1 --- /dev/null +++ b/ui/src/components/ui/Rate/Rate.tsx @@ -0,0 +1,231 @@ +import { + forwardRef, + useState, + useCallback, + useRef, + type KeyboardEvent, + type ReactNode, +} from 'react' +import classNames from 'classnames' +import { useConfig } from '../ConfigProvider' +import type { CommonProps } from '../@types/common' + +export interface RateProps extends CommonProps { + /** Toplam yıldız sayısı. Varsayılan: 5 */ + count?: number + /** Kontrollü değer */ + value?: number + /** Başlangıç değeri (kontrolsüz) */ + defaultValue?: number + /** Yarım yıldız desteği. Varsayılan: false */ + allowHalf?: boolean + /** Temizlemeye izin ver (aynı yıldıza tıkla = sıfırla). Varsayılan: true */ + allowClear?: boolean + /** Devre dışı */ + disabled?: boolean + /** Salt okunur */ + readOnly?: boolean + /** Özel ikon (dolu, boş) */ + character?: ReactNode | ((index: number) => ReactNode) + /** Tema rengi. Varsayılan: 'amber' */ + color?: string + /** Yıldız boyutu (px). Varsayılan: 20 */ + size?: number + /** Boşluk arası (px). Varsayılan: 4 */ + gap?: number + /** Değer değiştiğinde callback */ + onChange?: (value: number) => void + /** Hover değiştiğinde callback */ + onHoverChange?: (value: number) => void + /** Tooltip metinleri */ + tooltips?: string[] +} + +const StarIcon = ({ filled, half, size }: { filled: boolean; half: boolean; size: number }) => ( + + {half && ( + + + + + + + )} + + +) + +const Rate = forwardRef((props, ref) => { + const { + className, + style, + count = 5, + value: valueProp, + defaultValue = 0, + allowHalf = false, + allowClear = true, + disabled = false, + readOnly = false, + character, + color = 'amber', + size = 20, + gap = 4, + onChange, + onHoverChange, + tooltips, + ...rest + } = props + + const { themeColor, primaryColorLevel } = useConfig() + const isControlled = valueProp !== undefined + const [internalValue, setInternalValue] = useState(defaultValue) + const [hoverValue, setHoverValue] = useState(null) + const lastClickedRef = useRef(null) + + const value = isControlled ? valueProp! : internalValue + const displayValue = hoverValue !== null ? hoverValue : value + + const resolveColor = () => { + if (color === 'theme') return `text-${themeColor}-${primaryColorLevel}` + return `text-${color}-400` + } + + const getStarValue = (index: number, offsetX: number, starWidth: number) => { + if (allowHalf && offsetX < starWidth / 2) { + return index + 0.5 + } + return index + 1 + } + + const handleMouseMove = useCallback( + (index: number, e: React.MouseEvent) => { + if (disabled || readOnly) return + const rect = e.currentTarget.getBoundingClientRect() + const offsetX = e.clientX - rect.left + const newVal = getStarValue(index, offsetX, rect.width) + if (newVal !== hoverValue) { + setHoverValue(newVal) + onHoverChange?.(newVal) + } + }, + [disabled, readOnly, hoverValue, allowHalf, onHoverChange], + ) + + const handleMouseLeave = useCallback(() => { + if (disabled || readOnly) return + setHoverValue(null) + onHoverChange?.(0) + }, [disabled, readOnly, onHoverChange]) + + const handleClick = useCallback( + (index: number, e: React.MouseEvent) => { + if (disabled || readOnly) return + const rect = e.currentTarget.getBoundingClientRect() + const offsetX = e.clientX - rect.left + const clickedVal = getStarValue(index, offsetX, rect.width) + + let newVal = clickedVal + if (allowClear && clickedVal === lastClickedRef.current) { + newVal = 0 + lastClickedRef.current = null + } else { + lastClickedRef.current = clickedVal + } + + if (!isControlled) setInternalValue(newVal) + onChange?.(newVal) + }, + [disabled, readOnly, allowClear, allowHalf, isControlled, onChange], + ) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (disabled || readOnly) return + let newVal = value + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + newVal = Math.min(value + (allowHalf ? 0.5 : 1), count) + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + newVal = Math.max(value - (allowHalf ? 0.5 : 1), 0) + } else { + return + } + e.preventDefault() + if (!isControlled) setInternalValue(newVal) + onChange?.(newVal) + }, + [disabled, readOnly, value, allowHalf, count, isControlled, onChange], + ) + + const renderStar = (index: number) => { + const filled = displayValue >= index + 1 + const half = !filled && allowHalf && displayValue >= index + 0.5 + const tooltip = tooltips?.[index] + + const starNode = + typeof character === 'function' + ? character(index) + : character ?? ( + + ) + + return ( + handleMouseMove(index, e)} + onClick={(e) => handleClick(index, e)} + role={!disabled && !readOnly ? 'radio' : undefined} + aria-checked={displayValue >= index + 1} + aria-label={tooltip ?? `${index + 1} yıldız`} + > + {starNode} + + ) + } + + return ( +
    + {Array.from({ length: count }, (_, i) => renderStar(i))} +
    + ) +}) + +Rate.displayName = 'Rate' + +export default Rate diff --git a/ui/src/components/ui/Rate/index.tsx b/ui/src/components/ui/Rate/index.tsx new file mode 100644 index 0000000..c8fe4d7 --- /dev/null +++ b/ui/src/components/ui/Rate/index.tsx @@ -0,0 +1,2 @@ +export { default } from './Rate' +export type { RateProps } from './Rate' diff --git a/ui/src/components/ui/Slider/Slider.tsx b/ui/src/components/ui/Slider/Slider.tsx new file mode 100644 index 0000000..a56e7da --- /dev/null +++ b/ui/src/components/ui/Slider/Slider.tsx @@ -0,0 +1,380 @@ +import { + forwardRef, + useState, + useRef, + useCallback, + useEffect, + type KeyboardEvent, + type PointerEvent as ReactPointerEvent, +} from 'react' +import classNames from 'classnames' +import { useConfig } from '../ConfigProvider' +import type { CommonProps } from '../@types/common' + +export type SliderValue = number | [number, number] + +export interface SliderProps extends CommonProps { + /** Kontrollü değer (tek veya range) */ + value?: SliderValue + /** Başlangıç değeri (kontrolsüz) */ + defaultValue?: SliderValue + /** Minimum değer. Varsayılan: 0 */ + min?: number + /** Maksimum değer. Varsayılan: 100 */ + max?: number + /** Adım büyüklüğü. Varsayılan: 1 */ + step?: number + /** Range modu (iki handle). Varsayılan: false */ + range?: boolean + /** Yön. Varsayılan: 'horizontal' */ + orientation?: 'horizontal' | 'vertical' + /** Track yüksekliği/genişliği (px). Varsayılan: 4 */ + trackSize?: number + /** Handle çapı (px). Varsayılan: 18 */ + handleSize?: number + /** Devre dışı */ + disabled?: boolean + /** Salt okunur */ + readOnly?: boolean + /** Tooltip göster */ + tooltip?: boolean + /** Adım işaretleri göster */ + marks?: boolean | { value: number; label?: string }[] + /** Değer değiştiğinde (sürükleme dahil) */ + onChange?: (value: SliderValue) => void + /** Sadece bırakıldığında */ + onAfterChange?: (value: SliderValue) => void + /** Input adı */ + name?: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + field?: any +} + +const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)) +const snap = (v: number, step: number, min: number) => + Math.round((v - min) / step) * step + min + +const Slider = forwardRef((props, ref) => { + const { + className, + style, + value: valueProp, + defaultValue, + min = 0, + max = 100, + step = 1, + range = false, + orientation = 'horizontal', + trackSize = 4, + handleSize = 18, + disabled = false, + readOnly = false, + tooltip = false, + marks, + onChange, + onAfterChange, + name, + field, + ...rest + } = props + + const isVertical = orientation === 'vertical' + const isControlled = valueProp !== undefined + + const resolveDefault = (): SliderValue => { + if (field?.value !== undefined) return field.value + if (isControlled) return valueProp! + if (defaultValue !== undefined) return defaultValue + return range ? [min, max] : min + } + + const [internalValue, setInternalValue] = useState(resolveDefault) + const activeHandle = useRef<0 | 1>(0) + const trackRef = useRef(null) + const dragging = useRef(false) + const [showTooltip, setShowTooltip] = useState([false, false]) + + useEffect(() => { + if (isControlled) setInternalValue(valueProp!) + }, [isControlled, valueProp]) + + useEffect(() => { + if (field?.value !== undefined) setInternalValue(field.value) + }, [field?.value]) + + const { themeColor, primaryColorLevel } = useConfig() + + const currentValue = isControlled ? valueProp! : internalValue + + const toArray = (v: SliderValue): [number, number] => + Array.isArray(v) ? v : [v, v] + + const commit = useCallback( + (next: SliderValue, final = false) => { + if (!isControlled) setInternalValue(next) + field?.onChange?.(next) + onChange?.(next) + if (final) onAfterChange?.(next) + }, + [isControlled, field, onChange, onAfterChange], + ) + + const percentOf = (v: number) => ((v - min) / (max - min)) * 100 + + const valueFromPointer = useCallback( + (clientX: number, clientY: number): number => { + const track = trackRef.current + if (!track) return min + const rect = track.getBoundingClientRect() + let ratio: number + if (isVertical) { + ratio = 1 - (clientY - rect.top) / rect.height + } else { + ratio = (clientX - rect.left) / rect.width + } + const raw = min + clamp(ratio, 0, 1) * (max - min) + return clamp(snap(raw, step, min), min, max) + }, + [isVertical, min, max, step], + ) + + const handlePointerDown = ( + e: ReactPointerEvent, + handle: 0 | 1, + ) => { + if (disabled || readOnly) return + e.currentTarget.setPointerCapture(e.pointerId) + activeHandle.current = handle + dragging.current = true + setShowTooltip((t) => { + const next = [...t] + next[handle] = true + return next + }) + } + + const handlePointerMove = useCallback( + (e: ReactPointerEvent) => { + if (!dragging.current || disabled || readOnly) return + const newVal = valueFromPointer(e.clientX, e.clientY) + if (range) { + const [a, b] = toArray(currentValue) + const next: [number, number] = + activeHandle.current === 0 + ? [Math.min(newVal, b), b] + : [a, Math.max(newVal, a)] + commit(next) + } else { + commit(newVal) + } + }, + [disabled, readOnly, valueFromPointer, range, currentValue, commit], + ) + + const handlePointerUp = useCallback( + (e: ReactPointerEvent) => { + if (!dragging.current) return + dragging.current = false + const newVal = valueFromPointer(e.clientX, e.clientY) + const final = range + ? (() => { + const [a, b] = toArray(currentValue) + return activeHandle.current === 0 + ? ([Math.min(newVal, b), b] as [number, number]) + : ([a, Math.max(newVal, a)] as [number, number]) + })() + : newVal + commit(final, true) + setShowTooltip([false, false]) + }, + [valueFromPointer, range, currentValue, commit], + ) + + // Track tıklama (handle dışı) + const handleTrackClick = useCallback( + (e: React.MouseEvent) => { + if (disabled || readOnly || dragging.current) return + const newVal = valueFromPointer(e.clientX, e.clientY) + if (range) { + const [a, b] = toArray(currentValue) + const distA = Math.abs(newVal - a) + const distB = Math.abs(newVal - b) + const next: [number, number] = + distA <= distB + ? [clamp(snap(newVal, step, min), min, b), b] + : [a, clamp(snap(newVal, step, min), a, max)] + commit(next, true) + } else { + commit(newVal, true) + } + }, + [disabled, readOnly, valueFromPointer, range, currentValue, step, min, max, commit], + ) + + const handleKeyDown = (e: KeyboardEvent, handle: 0 | 1) => { + if (disabled || readOnly) return + const [a, b] = toArray(currentValue) + const cur = handle === 0 ? a : b + let next = cur + switch (e.key) { + case 'ArrowRight': + case 'ArrowUp': + e.preventDefault(); next = clamp(snap(cur + step, step, min), min, max); break + case 'ArrowLeft': + case 'ArrowDown': + e.preventDefault(); next = clamp(snap(cur - step, step, min), min, max); break + case 'Home': + e.preventDefault(); next = min; break + case 'End': + e.preventDefault(); next = max; break + case 'PageUp': + e.preventDefault(); next = clamp(snap(cur + step * 10, step, min), min, max); break + case 'PageDown': + e.preventDefault(); next = clamp(snap(cur - step * 10, step, min), min, max); break + default: return + } + if (range) { + const result: [number, number] = + handle === 0 ? [Math.min(next, b), b] : [a, Math.max(next, a)] + commit(result, true) + } else { + commit(next, true) + } + } + + // Görsel hesaplamalar + const [v0, v1] = toArray(currentValue) + const p0 = percentOf(v0) + const p1 = percentOf(v1) + + const trackFillStyle = isVertical + ? range + ? { bottom: `${p0}%`, top: `${100 - p1}%` } + : { bottom: 0, top: `${100 - p0}%` } + : range + ? { left: `${p0}%`, right: `${100 - p1}%` } + : { left: 0, width: `${p0}%` } + + const handle0Style = isVertical + ? { bottom: `calc(${p0}% - ${handleSize / 2}px)` } + : { left: `calc(${p0}% - ${handleSize / 2}px)` } + const handle1Style = isVertical + ? { bottom: `calc(${p1}% - ${handleSize / 2}px)` } + : { left: `calc(${p1}% - ${handleSize / 2}px)` } + + // Marks hesapla + const resolvedMarks = marks === true + ? Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, i) => ({ + value: min + i * step, + })) + : Array.isArray(marks) + ? marks + : [] + + const ringClass = `focus:ring-${themeColor}-${primaryColorLevel}` + + const renderHandle = (handle: 0 | 1) => { + const val = handle === 0 ? v0 : v1 + const hStyle = handle === 0 ? handle0Style : handle1Style + const show = showTooltip[handle] + return ( +
    handlePointerDown(e, handle)} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onKeyDown={(e) => handleKeyDown(e, handle)} + onFocus={() => + tooltip && setShowTooltip((t) => { const n = [...t]; n[handle] = true; return n }) + } + onBlur={() => + setShowTooltip((t) => { const n = [...t]; n[handle] = false; return n }) + } + > + {(tooltip || show) && show && ( +
    {val}
    + )} +
    + ) + } + + return ( +
    + {name && ( + + )} +
    +
    + + {renderHandle(0)} + {range && renderHandle(1)} +
    + + {/* Marks */} + {resolvedMarks.length > 0 && ( +
    + {resolvedMarks.map((m) => { + const pct = percentOf(m.value) + const markStyle = isVertical + ? { bottom: `${pct}%` } + : { left: `${pct}%` } + const active = range ? m.value >= v0 && m.value <= v1 : m.value <= v0 + return ( + + + {m.label && ( + {m.label} + )} + + ) + })} +
    + )} +
    + ) +}) + +Slider.displayName = 'Slider' + +export default Slider diff --git a/ui/src/components/ui/Slider/index.tsx b/ui/src/components/ui/Slider/index.tsx new file mode 100644 index 0000000..aca0079 --- /dev/null +++ b/ui/src/components/ui/Slider/index.tsx @@ -0,0 +1,2 @@ +export { default } from './Slider' +export type { SliderProps, SliderValue } from './Slider' diff --git a/ui/src/components/ui/index.ts b/ui/src/components/ui/index.ts index 9745086..7603486 100644 --- a/ui/src/components/ui/index.ts +++ b/ui/src/components/ui/index.ts @@ -1,10 +1,14 @@ export { default as Alert } from './Alert' +export { default as AutoComplete } from './AutoComplete' export { default as Avatar } from './Avatar' export { default as Badge } from './Badge' +export { default as Breadcrumb } from './Breadcrumb' export { default as Button } from './Button' export { default as Calendar } from './Calendar' export { default as Card } from './Card' export { default as Checkbox } from './Checkbox' +export { default as Chips } from './Chips' +export { default as ColorPicker } from './ColorPicker' export { default as ConfigProvider } from './ConfigProvider' export { default as DatePicker } from './DatePicker' export { default as Dialog } from './Dialog' @@ -13,8 +17,11 @@ export { default as Dropdown } from './Dropdown' export { default as FormItem } from './Form/FormItem' export { default as FormContainer } from './Form/FormContainer' export { default as hooks } from './hooks' +export { default as ImageViewer } from './ImageViewer' export { default as Input } from './Input' export { default as InputGroup } from './InputGroup' +export { default as Knob } from './Knob' +export { default as Marquee } from './Marquee' export { default as Menu } from './Menu' export { default as MenuItem } from './MenuItem' export { default as Notification } from './Notification' @@ -22,10 +29,12 @@ export { default as Pagination } from './Pagination' export { default as Progress } from './Progress' export { default as Radio } from './Radio' export { default as RangeCalendar } from './RangeCalendar' +export { default as Rate } from './Rate' export { default as ScrollBar } from './ScrollBar' export { default as Segment } from './Segment' export { default as Select } from './Select' export { default as Skeleton } from './Skeleton' +export { default as Slider } from './Slider' export { default as Spinner } from './Spinner' export { default as Steps } from './Steps' export { default as Switcher } from './Switcher' @@ -76,12 +85,21 @@ export type { MenuItemProps as BaseMenuItemProps } from './MenuItem' export type { NotificationProps } from './Notification' export type { PaginationProps } from './Pagination' export type { ProgressProps } from './Progress' +export type { AutoCompleteProps, AutoCompleteOption } from './AutoComplete' +export type { BreadcrumbProps, BreadcrumbItemProps } from './Breadcrumb' +export type { ChipsProps } from './Chips' +export type { ColorPickerProps } from './ColorPicker' +export type { ImageViewerProps, ImageViewerImage } from './ImageViewer' +export type { KnobProps } from './Knob' +export type { MarqueeProps } from './Marquee' export type { RadioProps } from './Radio' +export type { RateProps } from './Rate' export type { RangeCalendarProps } from './RangeCalendar' export type { ScrollbarProps, ScrollbarRef } from './ScrollBar' export type { SegmentProps, SegmentItemProps } from './Segment' export type { SelectProps } from './Select' export type { SkeletonProps } from './Skeleton' +export type { SliderProps, SliderValue } from './Slider' export type { SpinnerProps } from './Spinner' export type { StepsProps, StepItemProps } from './Steps' export type { SwitcherProps } from './Switcher'