New Components

This commit is contained in:
Sedat ÖZTÜRK 2026-05-13 11:44:48 +03:00
parent ffea9710e4
commit df9b6ff362
30 changed files with 3466 additions and 1 deletions

View file

@ -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 },

View file

@ -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;
}

View file

@ -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; }

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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%;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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";

View file

@ -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<AutoCompleteOption[]> */
fetchOptions?: (query: string) => Promise<AutoCompleteOption[]>
/** 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<T extends (...args: Parameters<T>) => void>(
fn: T,
delay: number,
) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
return useCallback(
(...args: Parameters<T>) => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => fn(...args), delay)
},
[fn, delay],
)
}
// ── Komponent ────────────────────────────────────────────────────────────────
const AutoComplete = forwardRef<HTMLInputElement, AutoCompleteProps>(
(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<HTMLDivElement>(null)
const listRef = useRef<HTMLUListElement>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div
ref={containerRef}
className={classNames('autocomplete', className)}
style={style}
>
<div className="autocomplete-input-wrapper">
<input
ref={ref}
name={name ?? field?.name}
value={inputValue}
disabled={disabled}
placeholder={placeholder}
autoComplete="off"
className={classNames(
'autocomplete-input',
sizeClass,
invalid ? invalidClass : ringClass,
disabled && 'autocomplete-input-disabled',
clearable &&
inputValue &&
'autocomplete-input-clearable',
)}
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={
activeIndex >= 0
? `autocomplete-opt-${activeIndex}`
: undefined
}
onChange={handleChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
{...rest}
/>
<div className="autocomplete-suffix">
{loading && (
<Spinner
size={14}
className="autocomplete-spinner"
/>
)}
{clearable && inputValue && !loading && (
<button
type="button"
className="autocomplete-clear"
onClick={handleClear}
tabIndex={-1}
aria-label="Temizle"
>
<svg
width={12}
height={12}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</div>
{open && (
<ul
ref={listRef}
id={listboxId}
role="listbox"
className="autocomplete-dropdown"
>
{loading ? (
<li className="autocomplete-option autocomplete-option-info">
{loadingText}
</li>
) : filteredOptions.length === 0 ? (
<li className="autocomplete-option autocomplete-option-info">
{noOptionsText}
</li>
) : (
filteredOptions.map((option, index) => (
<li
key={option.value}
id={`autocomplete-opt-${index}`}
role="option"
aria-selected={activeIndex === index}
aria-disabled={option.disabled}
className={classNames(
'autocomplete-option',
activeIndex === index &&
'autocomplete-option-active',
option.disabled &&
'autocomplete-option-disabled',
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (!option.disabled)
commitSelection(option)
}}
onMouseEnter={() => setActiveIndex(index)}
>
{renderOption
? renderOption(option, activeIndex === index)
: option.label}
</li>
))
)}
</ul>
)}
</div>
)
},
)
AutoComplete.displayName = 'AutoComplete'
export default AutoComplete

View file

@ -0,0 +1,2 @@
export { default } from './AutoComplete'
export type { AutoCompleteProps, AutoCompleteOption } from './AutoComplete'

View file

@ -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<HTMLLIElement, BreadcrumbItemProps>(
(props, ref) => {
const {
className,
children,
style,
href,
as: Component,
active = false,
icon,
onClick,
...rest
} = props
const Tag = Component ?? (href ? 'a' : 'span')
return (
<li
ref={ref}
className={classNames(
'breadcrumb-item',
active && 'breadcrumb-item-active',
className,
)}
style={style}
aria-current={active ? 'page' : undefined}
{...rest}
>
<Tag
href={href}
className={classNames(
'breadcrumb-link',
active
? 'breadcrumb-link-active'
: 'breadcrumb-link-default',
onClick && !active && 'cursor-pointer',
)}
onClick={onClick}
>
{icon && (
<span className="breadcrumb-icon">{icon}</span>
)}
{children}
</Tag>
</li>
)
},
)
BreadcrumbItem.displayName = 'BreadcrumbItem'
const Breadcrumb = forwardRef<HTMLElement, BreadcrumbProps>((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<BreadcrumbItemProps>, {
active: isLast,
})
return (
<span key={index} className="breadcrumb-entry" style={{ gap }}>
{index === 1 && collapsedCount > 0 && (
<>
<span
className="breadcrumb-separator"
aria-hidden
style={{ marginInline: gap }}
>
{separator}
</span>
<span
className="breadcrumb-collapsed"
title={`${collapsedCount} öğe gizlendi`}
>
...
</span>
</>
)}
{index > 0 && (
<span
className="breadcrumb-separator"
aria-hidden
style={{ marginInline: gap }}
>
{separator}
</span>
)}
{cloned}
</span>
)
})
return (
<nav
ref={ref}
aria-label="breadcrumb"
className={classNames(
'breadcrumb-nav',
`breadcrumb-${size}`,
className,
)}
style={style}
{...rest}
>
<ol className="breadcrumb-list">{renderedItems}</ol>
</nav>
)
})
Breadcrumb.displayName = 'Breadcrumb'
const BreadcrumbWithItem = Breadcrumb as typeof Breadcrumb & {
Item: typeof BreadcrumbItem
}
BreadcrumbWithItem.Item = BreadcrumbItem
export default BreadcrumbWithItem

View file

@ -0,0 +1,2 @@
export { default } from './Breadcrumb'
export type { BreadcrumbProps, BreadcrumbItemProps } from './Breadcrumb'

View file

@ -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<HTMLDivElement, ChipsProps>((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<string[]>(
field?.value ?? (isControlled ? valueProp! : defaultValue),
)
const [inputVal, setInputVal] = useState('')
const [focusedChip, setFocusedChip] = useState<number | null>(null)
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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<HTMLSpanElement>, 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 (
<div
ref={ref}
className={classNames(
'chips',
minH,
ringClass,
invalid && 'chips-invalid',
disabled && 'chips-disabled',
className,
)}
style={style}
onClick={() => inputRef.current?.focus()}
{...rest}
>
{chips.map((chip, i) => (
<span
key={i}
className="chips-item"
tabIndex={0}
role="option"
aria-selected
aria-label={chip}
onFocus={() => setFocusedChip(i)}
onBlur={() => setFocusedChip(null)}
onKeyDown={(e) => handleChipKeyDown(e, i)}
data-focused={focusedChip === i}
>
<span className="chips-item-label">
{itemTemplate ? itemTemplate(chip) : chip}
</span>
{!disabled && (
<button
type="button"
className="chips-item-remove"
onClick={(e) => { e.stopPropagation(); removeChip(i) }}
tabIndex={-1}
aria-label={`${chip} kaldır`}
>
<svg width={10} height={10} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</span>
))}
{!isMaxReached && !disabled && (
<input
ref={inputRef}
name={name ?? field?.name}
className="chips-input"
value={inputVal}
placeholder={chips.length === 0 ? placeholder : undefined}
disabled={disabled}
autoComplete="off"
aria-label="Chip ekle"
onChange={(e) => setInputVal(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => {
if (inputVal.trim()) addChip(inputVal)
}}
/>
)}
</div>
)
})
Chips.displayName = 'Chips'
export default Chips

View file

@ -0,0 +1,2 @@
export { default } from './Chips'
export type { ChipsProps } from './Chips'

View file

@ -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<HTMLDivElement, ColorPickerProps>((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<string>(resolveInitial)
const [inputText, setInputText] = useState<string>(resolveInitial)
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const nativeRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
const hex = e.target.value
setInputText(hex)
commit(hex)
}
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
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 (
<div
ref={(node) => {
;(
containerRef as React.MutableRefObject<HTMLDivElement | null>
).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ğı) */}
<input
ref={nativeRef}
type="color"
name={name ?? field?.name}
value={currentHex}
disabled={disabled}
className="color-picker-native"
tabIndex={-1}
aria-hidden
onChange={handleNativeChange}
/>
{/* Tetikleyici swatch */}
<button
type="button"
className={classNames(
'color-picker-swatch',
disabled && 'color-picker-disabled',
)}
style={{ backgroundColor: currentHex }}
disabled={disabled}
aria-label="Renk seç"
onClick={() => {
if (disabled) return
setOpen((o) => !o)
}}
/>
{/* Popup panel */}
{open && (
<div className="color-picker-panel">
{/* Native picker (geniş spektrum) */}
<div className="color-picker-spectrum-wrapper">
<input
type="color"
value={currentHex}
className="color-picker-spectrum"
onChange={handleNativeChange}
/>
</div>
{/* Preset renkler */}
{presets && presets.length > 0 && (
<div className="color-picker-presets">
{presets.map((color) => (
<button
key={color}
type="button"
className={classNames(
'color-picker-preset-dot',
currentHex.toLowerCase() ===
color.toLowerCase() &&
'color-picker-preset-dot-active',
)}
style={{ backgroundColor: color }}
title={color}
onClick={() => handlePresetClick(color)}
aria-label={color}
/>
))}
</div>
)}
{/* Hex input */}
{showInput && (
<div className="color-picker-input-row">
<span className="color-picker-input-label">HEX</span>
<input
className="color-picker-text-input"
value={inputText}
maxLength={7}
onChange={handleInputChange}
onBlur={handleInputBlur}
spellCheck={false}
/>
</div>
)}
{/* RGB inputs */}
{showRgb && rgb && (
<div className="color-picker-rgb-row">
{(['r', 'g', 'b'] as const).map((ch) => (
<div key={ch} className="color-picker-rgb-field">
<input
className="color-picker-text-input"
type="number"
min={0}
max={255}
value={rgb[ch]}
onChange={(e) =>
handleRgbChange(ch, e.target.value)
}
/>
<span className="color-picker-input-label">
{ch.toUpperCase()}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
)
})
ColorPicker.displayName = 'ColorPicker'
export default ColorPicker

View file

@ -0,0 +1,2 @@
export { default } from './ColorPicker'
export type { ColorPickerProps } from './ColorPicker'

View file

@ -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 = () => (
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
)
const IconPrev = () => (
<svg width={24} height={24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<polyline points="15 18 9 12 15 6" />
</svg>
)
const IconNext = () => (
<svg width={24} height={24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<polyline points="9 18 15 12 9 6" />
</svg>
)
const IconZoomIn = () => (
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
<line x1="11" y1="8" x2="11" y2="14" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
)
const IconZoomOut = () => (
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
<line x1="8" y1="11" x2="14" y2="11" />
</svg>
)
const IconReset = () => (
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 .49-4.95" />
</svg>
)
const IconRotateCW = () => (
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-.49-4.95" />
</svg>
)
const IconRotateCCW = () => (
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 .49-4.95" />
</svg>
)
const IconDownload = () => (
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)
// ── 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<HTMLImageElement>(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:[<mime>];base64,<data> veya data:[<mime>],<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<HTMLDivElement>) => {
e.preventDefault()
if (e.deltaY < 0) zoomIn()
else zoomOut()
}
// Drag (pan)
const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (e.target === e.currentTarget) onClose()
}
return (
<div
className="image-viewer-overlay"
role="dialog"
aria-modal
aria-label={current.alt ?? 'Resim görüntüleyici'}
>
{/* Toolbar */}
{showToolbar && (
<div className="image-viewer-toolbar">
<span className="image-viewer-counter">
{index + 1} / {images.length}
</span>
{current.caption && (
<span className="image-viewer-caption">{current.caption}</span>
)}
<div className="image-viewer-toolbar-actions">
{toolbarExtra}
<button type="button" onClick={zoomOut} title="Uzaklaştır (-)">
<IconZoomOut />
</button>
<span className="image-viewer-zoom-label">
{Math.round(zoom * 100)}%
</span>
<button type="button" onClick={zoomIn} title="Yaklaştır (+)">
<IconZoomIn />
</button>
<button type="button" onClick={resetTransform} title="Sıfırla">
<IconReset />
</button>
<button type="button" onClick={rotateCCW} title="Sola döndür (R)">
<IconRotateCCW />
</button>
<button type="button" onClick={rotateCW} title="Sağa döndür (r)">
<IconRotateCW />
</button>
<button type="button" onClick={handleDownload} title="İndir">
<IconDownload />
</button>
<button type="button" onClick={onClose} title="Kapat (Esc)" className="image-viewer-close">
<IconClose />
</button>
</div>
</div>
)}
{/* Stage */}
<div
className={classNames(
'image-viewer-stage',
dragging && 'image-viewer-dragging',
)}
onClick={overlayClick}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<img
ref={imgRef}
src={current.src}
alt={current.alt ?? ''}
className="image-viewer-img"
draggable={false}
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom}) rotate(${rotation}deg)`,
cursor: zoom > 1 ? (dragging ? 'grabbing' : 'grab') : 'default',
}}
/>
</div>
{/* Prev / Next */}
{hasPrev && (
<button
type="button"
className="image-viewer-nav image-viewer-nav-prev"
onClick={goPrev}
aria-label="Önceki"
>
<IconPrev />
</button>
)}
{hasNext && (
<button
type="button"
className="image-viewer-nav image-viewer-nav-next"
onClick={goNext}
aria-label="Sonraki"
>
<IconNext />
</button>
)}
{/* Thumbnails */}
{showThumbnails && images.length > 1 && (
<div className="image-viewer-thumbnails">
{images.map((img, i) => (
<button
key={i}
type="button"
onClick={() => onIndexChange(i)}
className={classNames(
'image-viewer-thumb',
i === index && 'image-viewer-thumb-active',
)}
aria-label={img.alt ?? `Resim ${i + 1}`}
aria-pressed={i === index}
>
<img
src={img.thumbnail ?? img.src}
alt={img.alt ?? ''}
draggable={false}
/>
</button>
))}
</div>
)}
</div>
)
}
// ── Ana Komponent ────────────────────────────────────────────────────────────
const ImageViewer = forwardRef<HTMLDivElement, ImageViewerProps>((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 && (
<div
ref={ref}
className={classNames('image-viewer-trigger', className)}
style={style}
{...rest}
>
{images.map((img, i) => (
<button
key={i}
type="button"
className="image-viewer-trigger-item"
onClick={() => openAt(i)}
aria-label={img.alt ?? `Resim ${i + 1}`}
>
<img
src={img.thumbnail ?? img.src}
alt={img.alt ?? ''}
draggable={false}
/>
</button>
))}
</div>
)}
{/* Portal overlay */}
{isOpen &&
createPortal(
<ImageViewerOverlay
images={images}
index={currentIndex}
loop={loop}
showToolbar={showToolbar}
showThumbnails={showThumbnails}
toolbarExtra={toolbarExtra}
zoomStep={zoomStep}
minZoom={minZoom}
maxZoom={maxZoom}
onClose={handleClose}
onIndexChange={handleIndexChange}
/>,
document.body,
)}
</>
)
})
ImageViewer.displayName = 'ImageViewer'
export default ImageViewer

View file

@ -0,0 +1,2 @@
export { default } from './ImageViewer'
export type { ImageViewerProps, ImageViewerImage } from './ImageViewer'

View file

@ -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<SVGSVGElement, KnobProps>((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<SVGSVGElement>(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<SVGSVGElement>) => {
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<SVGSVGElement>) => {
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<SVGSVGElement>) => {
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 (
<span className={classNames('knob-wrapper', className)} style={style}>
{name && (
<input type="hidden" name={name ?? field?.name} value={currentValue} />
)}
<svg
ref={(node) => {
;(svgRef as React.MutableRefObject<SVGSVGElement | null>).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ı */}
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={resolveRangeColor()}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={dashArray}
transform={trackRotation}
className="knob-range"
/>
{/* Değer yayı */}
{valueArcLength > 0 && (
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={resolveValueColor()}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={valueDash}
transform={trackRotation}
className="knob-value-arc"
/>
)}
{/* Handle noktası */}
{(() => {
const handleAngle = (valueAngleDeg - 90) * (Math.PI / 180)
const hx = cx + r * Math.cos(handleAngle)
const hy = cy + r * Math.sin(handleAngle)
return (
<circle
cx={hx}
cy={hy}
r={strokeWidth / 2 + 1}
fill={resolveValueColor()}
className="knob-handle"
/>
)
})()}
{/* Etiket */}
{showValue && (
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fontSize={size * 0.2}
fontWeight={600}
fill={resolveTextColor()}
className="knob-label"
>
{label}
</text>
)}
</svg>
</span>
)
})
Knob.displayName = 'Knob'
export default Knob

View file

@ -0,0 +1,2 @@
export { default } from './Knob'
export type { KnobProps } from './Knob'

View file

@ -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<HTMLDivElement, MarqueeProps>((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<HTMLDivElement>(null)
const trackRef = useRef<HTMLDivElement>(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) => (
<Fragment key={i}>{children}</Fragment>
))
return (
<div
ref={(node) => {
;(containerRef as React.MutableRefObject<HTMLDivElement | null>).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 && (
<>
<div
ref={trackRef}
className={classNames(
'marquee-track',
animationClass,
(pauseOnHover || isPaused) &&
'marquee-pause-on-hover',
(pauseOnClick || isPaused) &&
isPaused &&
'marquee-paused',
)}
style={animationStyle}
onAnimationIteration={handleAnimationIteration}
onAnimationEnd={handleAnimationEnd}
aria-hidden={multiplier > 1}
>
{clonedItems}
</div>
{/* Klonlanmış track - kesintisiz görünüm için */}
<div
className={classNames(
'marquee-track',
animationClass,
(pauseOnHover || isPaused) &&
'marquee-pause-on-hover',
isPaused && 'marquee-paused',
)}
style={animationStyle}
aria-hidden
>
{clonedItems}
</div>
</>
)}
</div>
)
})
Marquee.displayName = 'Marquee'
export default Marquee

View file

@ -0,0 +1,2 @@
export { default } from './Marquee'
export type { MarqueeProps } from './Marquee'

View file

@ -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 }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{half && (
<defs>
<linearGradient id="rate-half">
<stop offset="50%" stopColor="currentColor" />
<stop offset="50%" stopColor="transparent" />
</linearGradient>
</defs>
)}
<polygon
points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"
fill={
half
? 'url(#rate-half)'
: filled
? 'currentColor'
: 'none'
}
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
const Rate = forwardRef<HTMLDivElement, RateProps>((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<number | null>(null)
const lastClickedRef = useRef<number | null>(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<HTMLSpanElement>) => {
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<HTMLSpanElement>) => {
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<HTMLDivElement>) => {
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 ?? (
<StarIcon filled={filled} half={half} size={size} />
)
return (
<span
key={index}
className={classNames(
'rate-star',
filled || half ? resolveColor() : 'text-gray-300 dark:text-gray-600',
!disabled && !readOnly && 'cursor-pointer',
disabled && 'opacity-50 cursor-not-allowed',
)}
style={{ marginRight: index < count - 1 ? gap : 0 }}
title={tooltip}
onMouseMove={(e) => 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}
</span>
)
}
return (
<div
ref={ref}
className={classNames('rate', className)}
style={style}
onMouseLeave={handleMouseLeave}
onKeyDown={handleKeyDown}
tabIndex={disabled || readOnly ? undefined : 0}
role="radiogroup"
aria-label="Değerlendirme"
{...rest}
>
{Array.from({ length: count }, (_, i) => renderStar(i))}
</div>
)
})
Rate.displayName = 'Rate'
export default Rate

View file

@ -0,0 +1,2 @@
export { default } from './Rate'
export type { RateProps } from './Rate'

View file

@ -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<HTMLDivElement, SliderProps>((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<SliderValue>(resolveDefault)
const activeHandle = useRef<0 | 1>(0)
const trackRef = useRef<HTMLDivElement>(null)
const dragging = useRef(false)
const [showTooltip, setShowTooltip] = useState<boolean[]>([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<HTMLDivElement>,
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>, 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 (
<div
key={handle}
className={classNames(
'slider-handle',
ringClass,
disabled && 'slider-handle-disabled',
)}
style={{ ...hStyle, width: handleSize, height: handleSize }}
role="slider"
tabIndex={disabled || readOnly ? -1 : 0}
aria-valuemin={handle === 0 ? min : v0}
aria-valuemax={handle === 1 ? max : v1}
aria-valuenow={val}
aria-disabled={disabled}
aria-readonly={readOnly}
onPointerDown={(e) => 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 && (
<div className="slider-tooltip">{val}</div>
)}
</div>
)
}
return (
<div
ref={ref}
className={classNames(
'slider',
isVertical ? 'slider-vertical' : 'slider-horizontal',
disabled && 'slider-disabled',
className,
)}
style={style}
{...rest}
>
{name && (
<input
type="hidden"
name={name ?? field?.name}
value={Array.isArray(currentValue) ? currentValue.join(',') : currentValue}
/>
)}
<div
ref={trackRef}
className="slider-track"
style={
isVertical
? { width: trackSize }
: { height: trackSize }
}
onClick={handleTrackClick}
>
<div className="slider-fill" style={trackFillStyle} />
{renderHandle(0)}
{range && renderHandle(1)}
</div>
{/* Marks */}
{resolvedMarks.length > 0 && (
<div className={classNames('slider-marks', isVertical && 'slider-marks-vertical')}>
{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 (
<span key={m.value} className="slider-mark-wrapper" style={markStyle}>
<span
className={classNames(
'slider-mark-dot',
active && 'slider-mark-dot-active',
)}
/>
{m.label && (
<span className="slider-mark-label">{m.label}</span>
)}
</span>
)
})}
</div>
)}
</div>
)
})
Slider.displayName = 'Slider'
export default Slider

View file

@ -0,0 +1,2 @@
export { default } from './Slider'
export type { SliderProps, SliderValue } from './Slider'

View file

@ -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'