New Components
This commit is contained in:
parent
ffea9710e4
commit
df9b6ff362
30 changed files with 3466 additions and 1 deletions
|
|
@ -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)",
|
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[] {
|
InsertFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] {
|
||||||
new() { FieldName = "ConcurrencyStamp", FieldDbType = DbType.Guid, Value = Guid.NewGuid().ToString(), CustomValueType = FieldCustomValueTypeEnum.Value },
|
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[] {
|
FormFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] {
|
||||||
new() { FieldName = "Required", FieldDbType = DbType.Boolean, Value = "false", CustomValueType = FieldCustomValueTypeEnum.Value },
|
new() { FieldName = "Required", FieldDbType = DbType.Boolean, Value = "false", CustomValueType = FieldCustomValueTypeEnum.Value },
|
||||||
|
|
|
||||||
64
ui/src/assets/styles/components/_autocomplete.css
Normal file
64
ui/src/assets/styles/components/_autocomplete.css
Normal 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;
|
||||||
|
}
|
||||||
49
ui/src/assets/styles/components/_breadcrumb.css
Normal file
49
ui/src/assets/styles/components/_breadcrumb.css
Normal 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; }
|
||||||
45
ui/src/assets/styles/components/_chips.css
Normal file
45
ui/src/assets/styles/components/_chips.css
Normal 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;
|
||||||
|
}
|
||||||
89
ui/src/assets/styles/components/_color-picker.css
Normal file
89
ui/src/assets/styles/components/_color-picker.css
Normal 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;
|
||||||
|
}
|
||||||
96
ui/src/assets/styles/components/_image-viewer.css
Normal file
96
ui/src/assets/styles/components/_image-viewer.css
Normal 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;
|
||||||
|
}
|
||||||
33
ui/src/assets/styles/components/_knob.css
Normal file
33
ui/src/assets/styles/components/_knob.css
Normal 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%;
|
||||||
|
}
|
||||||
101
ui/src/assets/styles/components/_marquee.css
Normal file
101
ui/src/assets/styles/components/_marquee.css
Normal 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;
|
||||||
|
}
|
||||||
11
ui/src/assets/styles/components/_rate.css
Normal file
11
ui/src/assets/styles/components/_rate.css
Normal 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;
|
||||||
|
}
|
||||||
124
ui/src/assets/styles/components/_slider.css
Normal file
124
ui/src/assets/styles/components/_slider.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,35 @@
|
||||||
@import "./_alert.css";
|
@import "./_alert.css";
|
||||||
|
@import "./_autocomplete.css";
|
||||||
@import "./_avatar.css";
|
@import "./_avatar.css";
|
||||||
@import "./_badge.css";
|
@import "./_badge.css";
|
||||||
|
@import "./_breadcrumb.css";
|
||||||
@import "./_button.css";
|
@import "./_button.css";
|
||||||
@import "./_card.css";
|
@import "./_card.css";
|
||||||
@import "./_checkbox.css";
|
@import "./_checkbox.css";
|
||||||
|
@import "./_chips.css";
|
||||||
@import "./_close-button.css";
|
@import "./_close-button.css";
|
||||||
|
@import "./_color-picker.css";
|
||||||
@import "./_date-picker.css";
|
@import "./_date-picker.css";
|
||||||
@import "./_dialog.css";
|
@import "./_dialog.css";
|
||||||
@import "./_drawer.css";
|
@import "./_drawer.css";
|
||||||
@import "./_dropdown.css";
|
@import "./_dropdown.css";
|
||||||
@import "./_form.css";
|
@import "./_form.css";
|
||||||
|
@import "./_image-viewer.css";
|
||||||
@import "./_input-group.css";
|
@import "./_input-group.css";
|
||||||
@import "./_input.css";
|
@import "./_input.css";
|
||||||
|
@import "./_knob.css";
|
||||||
@import "./_menu-item.css";
|
@import "./_menu-item.css";
|
||||||
@import "./_menu.css";
|
@import "./_menu.css";
|
||||||
|
@import "./_marquee.css";
|
||||||
@import "./_notification.css";
|
@import "./_notification.css";
|
||||||
@import "./_pagination.css";
|
@import "./_pagination.css";
|
||||||
@import "./_progress.css";
|
@import "./_progress.css";
|
||||||
@import "./_radio.css";
|
@import "./_radio.css";
|
||||||
|
@import "./_rate.css";
|
||||||
@import "./_segment.css";
|
@import "./_segment.css";
|
||||||
@import "./_select.css";
|
@import "./_select.css";
|
||||||
@import "./_skeleton.css";
|
@import "./_skeleton.css";
|
||||||
|
@import "./_slider.css";
|
||||||
@import "./_steps.css";
|
@import "./_steps.css";
|
||||||
@import "./_switcher.css";
|
@import "./_switcher.css";
|
||||||
@import "./_tables.css";
|
@import "./_tables.css";
|
||||||
|
|
|
||||||
402
ui/src/components/ui/AutoComplete/AutoComplete.tsx
Normal file
402
ui/src/components/ui/AutoComplete/AutoComplete.tsx
Normal 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
|
||||||
2
ui/src/components/ui/AutoComplete/index.tsx
Normal file
2
ui/src/components/ui/AutoComplete/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from './AutoComplete'
|
||||||
|
export type { AutoCompleteProps, AutoCompleteOption } from './AutoComplete'
|
||||||
179
ui/src/components/ui/Breadcrumb/Breadcrumb.tsx
Normal file
179
ui/src/components/ui/Breadcrumb/Breadcrumb.tsx
Normal 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
|
||||||
2
ui/src/components/ui/Breadcrumb/index.tsx
Normal file
2
ui/src/components/ui/Breadcrumb/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from './Breadcrumb'
|
||||||
|
export type { BreadcrumbProps, BreadcrumbItemProps } from './Breadcrumb'
|
||||||
254
ui/src/components/ui/Chips/Chips.tsx
Normal file
254
ui/src/components/ui/Chips/Chips.tsx
Normal 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
|
||||||
2
ui/src/components/ui/Chips/index.tsx
Normal file
2
ui/src/components/ui/Chips/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from './Chips'
|
||||||
|
export type { ChipsProps } from './Chips'
|
||||||
309
ui/src/components/ui/ColorPicker/ColorPicker.tsx
Normal file
309
ui/src/components/ui/ColorPicker/ColorPicker.tsx
Normal 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
|
||||||
2
ui/src/components/ui/ColorPicker/index.tsx
Normal file
2
ui/src/components/ui/ColorPicker/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from './ColorPicker'
|
||||||
|
export type { ColorPickerProps } from './ColorPicker'
|
||||||
502
ui/src/components/ui/ImageViewer/ImageViewer.tsx
Normal file
502
ui/src/components/ui/ImageViewer/ImageViewer.tsx
Normal 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
|
||||||
2
ui/src/components/ui/ImageViewer/index.tsx
Normal file
2
ui/src/components/ui/ImageViewer/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from './ImageViewer'
|
||||||
|
export type { ImageViewerProps, ImageViewerImage } from './ImageViewer'
|
||||||
329
ui/src/components/ui/Knob/Knob.tsx
Normal file
329
ui/src/components/ui/Knob/Knob.tsx
Normal 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
|
||||||
2
ui/src/components/ui/Knob/index.tsx
Normal file
2
ui/src/components/ui/Knob/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from './Knob'
|
||||||
|
export type { KnobProps } from './Knob'
|
||||||
221
ui/src/components/ui/Marquee/Marquee.tsx
Normal file
221
ui/src/components/ui/Marquee/Marquee.tsx
Normal 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
|
||||||
2
ui/src/components/ui/Marquee/index.tsx
Normal file
2
ui/src/components/ui/Marquee/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from './Marquee'
|
||||||
|
export type { MarqueeProps } from './Marquee'
|
||||||
231
ui/src/components/ui/Rate/Rate.tsx
Normal file
231
ui/src/components/ui/Rate/Rate.tsx
Normal 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
|
||||||
2
ui/src/components/ui/Rate/index.tsx
Normal file
2
ui/src/components/ui/Rate/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from './Rate'
|
||||||
|
export type { RateProps } from './Rate'
|
||||||
380
ui/src/components/ui/Slider/Slider.tsx
Normal file
380
ui/src/components/ui/Slider/Slider.tsx
Normal 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
|
||||||
2
ui/src/components/ui/Slider/index.tsx
Normal file
2
ui/src/components/ui/Slider/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from './Slider'
|
||||||
|
export type { SliderProps, SliderValue } from './Slider'
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
export { default as Alert } from './Alert'
|
export { default as Alert } from './Alert'
|
||||||
|
export { default as AutoComplete } from './AutoComplete'
|
||||||
export { default as Avatar } from './Avatar'
|
export { default as Avatar } from './Avatar'
|
||||||
export { default as Badge } from './Badge'
|
export { default as Badge } from './Badge'
|
||||||
|
export { default as Breadcrumb } from './Breadcrumb'
|
||||||
export { default as Button } from './Button'
|
export { default as Button } from './Button'
|
||||||
export { default as Calendar } from './Calendar'
|
export { default as Calendar } from './Calendar'
|
||||||
export { default as Card } from './Card'
|
export { default as Card } from './Card'
|
||||||
export { default as Checkbox } from './Checkbox'
|
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 ConfigProvider } from './ConfigProvider'
|
||||||
export { default as DatePicker } from './DatePicker'
|
export { default as DatePicker } from './DatePicker'
|
||||||
export { default as Dialog } from './Dialog'
|
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 FormItem } from './Form/FormItem'
|
||||||
export { default as FormContainer } from './Form/FormContainer'
|
export { default as FormContainer } from './Form/FormContainer'
|
||||||
export { default as hooks } from './hooks'
|
export { default as hooks } from './hooks'
|
||||||
|
export { default as ImageViewer } from './ImageViewer'
|
||||||
export { default as Input } from './Input'
|
export { default as Input } from './Input'
|
||||||
export { default as InputGroup } from './InputGroup'
|
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 Menu } from './Menu'
|
||||||
export { default as MenuItem } from './MenuItem'
|
export { default as MenuItem } from './MenuItem'
|
||||||
export { default as Notification } from './Notification'
|
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 Progress } from './Progress'
|
||||||
export { default as Radio } from './Radio'
|
export { default as Radio } from './Radio'
|
||||||
export { default as RangeCalendar } from './RangeCalendar'
|
export { default as RangeCalendar } from './RangeCalendar'
|
||||||
|
export { default as Rate } from './Rate'
|
||||||
export { default as ScrollBar } from './ScrollBar'
|
export { default as ScrollBar } from './ScrollBar'
|
||||||
export { default as Segment } from './Segment'
|
export { default as Segment } from './Segment'
|
||||||
export { default as Select } from './Select'
|
export { default as Select } from './Select'
|
||||||
export { default as Skeleton } from './Skeleton'
|
export { default as Skeleton } from './Skeleton'
|
||||||
|
export { default as Slider } from './Slider'
|
||||||
export { default as Spinner } from './Spinner'
|
export { default as Spinner } from './Spinner'
|
||||||
export { default as Steps } from './Steps'
|
export { default as Steps } from './Steps'
|
||||||
export { default as Switcher } from './Switcher'
|
export { default as Switcher } from './Switcher'
|
||||||
|
|
@ -76,12 +85,21 @@ export type { MenuItemProps as BaseMenuItemProps } from './MenuItem'
|
||||||
export type { NotificationProps } from './Notification'
|
export type { NotificationProps } from './Notification'
|
||||||
export type { PaginationProps } from './Pagination'
|
export type { PaginationProps } from './Pagination'
|
||||||
export type { ProgressProps } from './Progress'
|
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 { RadioProps } from './Radio'
|
||||||
|
export type { RateProps } from './Rate'
|
||||||
export type { RangeCalendarProps } from './RangeCalendar'
|
export type { RangeCalendarProps } from './RangeCalendar'
|
||||||
export type { ScrollbarProps, ScrollbarRef } from './ScrollBar'
|
export type { ScrollbarProps, ScrollbarRef } from './ScrollBar'
|
||||||
export type { SegmentProps, SegmentItemProps } from './Segment'
|
export type { SegmentProps, SegmentItemProps } from './Segment'
|
||||||
export type { SelectProps } from './Select'
|
export type { SelectProps } from './Select'
|
||||||
export type { SkeletonProps } from './Skeleton'
|
export type { SkeletonProps } from './Skeleton'
|
||||||
|
export type { SliderProps, SliderValue } from './Slider'
|
||||||
export type { SpinnerProps } from './Spinner'
|
export type { SpinnerProps } from './Spinner'
|
||||||
export type { StepsProps, StepItemProps } from './Steps'
|
export type { StepsProps, StepItemProps } from './Steps'
|
||||||
export type { SwitcherProps } from './Switcher'
|
export type { SwitcherProps } from './Switcher'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue