@setzkasten-cms/ui 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@setzkasten-cms/ui",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "React-basierte Admin-UI für Setzkasten CMS — Page Builder, Editor, Field Renderers",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -43,7 +43,7 @@
43
43
  "react": "^19.1.0",
44
44
  "react-dom": "^19.1.0",
45
45
  "zustand": "^5.0.0",
46
- "@setzkasten-cms/core": "0.4.4"
46
+ "@setzkasten-cms/core": "0.4.6"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/react": "^19.1.0",
@@ -1,4 +1,4 @@
1
- import { memo, useCallback } from 'react'
1
+ import { memo, useCallback, useState, useRef } from 'react'
2
2
  import type { ArrayFieldDef, AnyFieldDef } from '@setzkasten-cms/core'
3
3
  import { useField } from '../hooks/use-field'
4
4
  import { FieldRenderer, type FieldRendererProps } from './field-renderer'
@@ -10,8 +10,13 @@ export const ArrayFieldRenderer = memo(function ArrayFieldRenderer({
10
10
  }: FieldRendererProps) {
11
11
  const arrayField = field as ArrayFieldDef
12
12
  const { value, setValue } = useField(store, path)
13
+ const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set())
14
+ const [dragIndex, setDragIndex] = useState<number | null>(null)
15
+ const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
16
+ const dragCounter = useRef(0)
13
17
 
14
18
  const items = (value as unknown[]) ?? []
19
+ const isGrid = arrayField.layout === 'grid'
15
20
 
16
21
  const addItem = useCallback(() => {
17
22
  const defaultValue = arrayField.itemField.defaultValue ?? (
@@ -33,6 +38,7 @@ export const ArrayFieldRenderer = memo(function ArrayFieldRenderer({
33
38
 
34
39
  const moveItem = useCallback(
35
40
  (from: number, to: number) => {
41
+ if (from === to) return
36
42
  const newItems = [...items]
37
43
  const [moved] = newItems.splice(from, 1)
38
44
  newItems.splice(to, 0, moved)
@@ -41,9 +47,65 @@ export const ArrayFieldRenderer = memo(function ArrayFieldRenderer({
41
47
  [items, setValue],
42
48
  )
43
49
 
50
+ const toggleCollapse = useCallback((index: number) => {
51
+ setCollapsedItems((prev) => {
52
+ const next = new Set(prev)
53
+ if (next.has(index)) next.delete(index)
54
+ else next.add(index)
55
+ return next
56
+ })
57
+ }, [])
58
+
59
+ // Drag & Drop handlers
60
+ const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
61
+ setDragIndex(index)
62
+ e.dataTransfer.effectAllowed = 'move'
63
+ e.dataTransfer.setData('text/plain', String(index))
64
+ }, [])
65
+
66
+ const handleDragEnter = useCallback((index: number) => {
67
+ dragCounter.current++
68
+ setDragOverIndex(index)
69
+ }, [])
70
+
71
+ const handleDragLeave = useCallback(() => {
72
+ dragCounter.current--
73
+ if (dragCounter.current === 0) {
74
+ setDragOverIndex(null)
75
+ }
76
+ }, [])
77
+
78
+ const handleDragOver = useCallback((e: React.DragEvent) => {
79
+ e.preventDefault()
80
+ e.dataTransfer.dropEffect = 'move'
81
+ }, [])
82
+
83
+ const handleDrop = useCallback(
84
+ (e: React.DragEvent, toIndex: number) => {
85
+ e.preventDefault()
86
+ dragCounter.current = 0
87
+ if (dragIndex !== null) {
88
+ moveItem(dragIndex, toIndex)
89
+ }
90
+ setDragIndex(null)
91
+ setDragOverIndex(null)
92
+ },
93
+ [dragIndex, moveItem],
94
+ )
95
+
96
+ const handleDragEnd = useCallback(() => {
97
+ dragCounter.current = 0
98
+ setDragIndex(null)
99
+ setDragOverIndex(null)
100
+ }, [])
101
+
44
102
  const canAdd = arrayField.maxItems === undefined || items.length < arrayField.maxItems
45
103
  const canRemove = arrayField.minItems === undefined || items.length > arrayField.minItems
46
104
 
105
+ const gridStyle = isGrid
106
+ ? { gridTemplateColumns: `repeat(${arrayField.gridColumns}, 1fr)` } as React.CSSProperties
107
+ : undefined
108
+
47
109
  return (
48
110
  <div className="sk-field sk-field--array">
49
111
  <div className="sk-array__header">
@@ -53,27 +115,50 @@ export const ArrayFieldRenderer = memo(function ArrayFieldRenderer({
53
115
  </label>
54
116
  </div>
55
117
 
56
- <div className="sk-array__items">
118
+ <div
119
+ className={`sk-array__items ${isGrid ? 'sk-array__items--grid' : ''}`}
120
+ style={gridStyle}
121
+ >
57
122
  {items.map((_, index) => {
58
123
  const itemLabel = arrayField.itemLabel
59
124
  ? arrayField.itemLabel(items[index], index)
60
125
  : `#${index + 1}`
61
126
 
127
+ const isCollapsed = arrayField.collapsible && collapsedItems.has(index)
128
+ const isDragging = dragIndex === index
129
+ const isDragOver = dragOverIndex === index && dragIndex !== index
130
+
62
131
  return (
63
- <div key={index} className="sk-array__item">
132
+ <div
133
+ key={index}
134
+ className={`sk-array__item ${isDragging ? 'sk-array__item--dragging' : ''} ${isDragOver ? 'sk-array__item--dragover' : ''}`}
135
+ draggable
136
+ onDragStart={(e) => handleDragStart(e, index)}
137
+ onDragEnter={() => handleDragEnter(index)}
138
+ onDragLeave={handleDragLeave}
139
+ onDragOver={handleDragOver}
140
+ onDrop={(e) => handleDrop(e, index)}
141
+ onDragEnd={handleDragEnd}
142
+ >
64
143
  <div className="sk-array__item-header">
144
+ <span className="sk-array__item-drag" title="Ziehen zum Sortieren">⠿</span>
65
145
  <span className="sk-array__item-label">{itemLabel}</span>
66
146
  <div className="sk-array__item-actions">
67
- {index > 0 && (
147
+ {!isGrid && index > 0 && (
68
148
  <button type="button" className="sk-button sk-button--sm" onClick={() => moveItem(index, index - 1)} title="Nach oben">
69
149
 
70
150
  </button>
71
151
  )}
72
- {index < items.length - 1 && (
152
+ {!isGrid && index < items.length - 1 && (
73
153
  <button type="button" className="sk-button sk-button--sm" onClick={() => moveItem(index, index + 1)} title="Nach unten">
74
154
 
75
155
  </button>
76
156
  )}
157
+ {arrayField.collapsible && (
158
+ <button type="button" className="sk-button sk-button--sm" onClick={() => toggleCollapse(index)} title={isCollapsed ? 'Aufklappen' : 'Zuklappen'}>
159
+ {isCollapsed ? '▸' : '▾'}
160
+ </button>
161
+ )}
77
162
  {canRemove && (
78
163
  <button type="button" className="sk-button sk-button--sm sk-button--danger" onClick={() => removeItem(index)} title="Entfernen">
79
164
  ×
@@ -81,11 +166,13 @@ export const ArrayFieldRenderer = memo(function ArrayFieldRenderer({
81
166
  )}
82
167
  </div>
83
168
  </div>
84
- <FieldRenderer
85
- field={arrayField.itemField as AnyFieldDef}
86
- path={[...path, index]}
87
- store={store}
88
- />
169
+ {!isCollapsed && (
170
+ <FieldRenderer
171
+ field={arrayField.itemField as AnyFieldDef}
172
+ path={[...path, index]}
173
+ store={store}
174
+ />
175
+ )}
89
176
  </div>
90
177
  )
91
178
  })}
@@ -0,0 +1,116 @@
1
+ import { memo, useState, useCallback, useRef, useEffect } from 'react'
2
+ import type { ColorFieldDef } from '@setzkasten-cms/core'
3
+ import { useField } from '../hooks/use-field'
4
+ import type { FieldRendererProps } from './field-renderer'
5
+
6
+ export const ColorFieldRenderer = memo(function ColorFieldRenderer({
7
+ field,
8
+ path,
9
+ store,
10
+ }: FieldRendererProps) {
11
+ const colorField = field as ColorFieldDef
12
+ const { value, errors, setValue, touch } = useField(store, path)
13
+ const [showPicker, setShowPicker] = useState(false)
14
+ const containerRef = useRef<HTMLDivElement>(null)
15
+
16
+ const currentColor = (value as string) || '#000000'
17
+
18
+ // Close picker on outside click
19
+ useEffect(() => {
20
+ if (!showPicker) return
21
+ const handleClick = (e: MouseEvent) => {
22
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
23
+ setShowPicker(false)
24
+ }
25
+ }
26
+ document.addEventListener('mousedown', handleClick)
27
+ return () => document.removeEventListener('mousedown', handleClick)
28
+ }, [showPicker])
29
+
30
+ const handleColorInput = useCallback(
31
+ (e: React.ChangeEvent<HTMLInputElement>) => {
32
+ setValue(e.target.value)
33
+ },
34
+ [setValue],
35
+ )
36
+
37
+ const handleHexInput = useCallback(
38
+ (e: React.ChangeEvent<HTMLInputElement>) => {
39
+ let hex = e.target.value
40
+ if (!hex.startsWith('#')) hex = '#' + hex
41
+ if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
42
+ setValue(hex)
43
+ }
44
+ },
45
+ [setValue],
46
+ )
47
+
48
+ const handlePresetClick = useCallback(
49
+ (preset: string) => {
50
+ setValue(preset)
51
+ },
52
+ [setValue],
53
+ )
54
+
55
+ return (
56
+ <div className="sk-field sk-field--color" ref={containerRef}>
57
+ <label className="sk-field__label">{colorField.label}</label>
58
+ {colorField.description && <p className="sk-field__description">{colorField.description}</p>}
59
+
60
+ <div className="sk-color__row">
61
+ <button
62
+ type="button"
63
+ className="sk-color__swatch"
64
+ style={{ backgroundColor: currentColor }}
65
+ onClick={() => setShowPicker(!showPicker)}
66
+ aria-label="Farbe wählen"
67
+ />
68
+ <input
69
+ className="sk-field__input sk-color__hex"
70
+ type="text"
71
+ value={currentColor}
72
+ onChange={handleHexInput}
73
+ onBlur={touch}
74
+ placeholder="#000000"
75
+ maxLength={7}
76
+ aria-label={colorField.label}
77
+ />
78
+ </div>
79
+
80
+ {showPicker && (
81
+ <div className="sk-color__picker">
82
+ <input
83
+ type="color"
84
+ className="sk-color__native"
85
+ value={currentColor}
86
+ onChange={handleColorInput}
87
+ />
88
+ </div>
89
+ )}
90
+
91
+ {colorField.presets.length > 0 && (
92
+ <div className="sk-color__presets">
93
+ {colorField.presets.map((preset) => (
94
+ <button
95
+ key={preset}
96
+ type="button"
97
+ className={`sk-color__preset ${preset === currentColor ? 'sk-color__preset--active' : ''}`}
98
+ style={{ backgroundColor: preset }}
99
+ onClick={() => handlePresetClick(preset)}
100
+ title={preset}
101
+ aria-label={`Farbe ${preset}`}
102
+ />
103
+ ))}
104
+ </div>
105
+ )}
106
+
107
+ {errors.length > 0 && (
108
+ <div className="sk-field__errors">
109
+ {errors.map((err, i) => (
110
+ <p key={i} className="sk-field__error">{err}</p>
111
+ ))}
112
+ </div>
113
+ )}
114
+ </div>
115
+ )
116
+ })
@@ -0,0 +1,39 @@
1
+ import { memo } from 'react'
2
+ import type { DateFieldDef } from '@setzkasten-cms/core'
3
+ import { useField } from '../hooks/use-field'
4
+ import type { FieldRendererProps } from './field-renderer'
5
+
6
+ export const DateFieldRenderer = memo(function DateFieldRenderer({
7
+ field,
8
+ path,
9
+ store,
10
+ }: FieldRendererProps) {
11
+ const dateField = field as DateFieldDef
12
+ const { value, errors, setValue, touch } = useField(store, path)
13
+
14
+ const inputType = dateField.includeTime ? 'datetime-local' : 'date'
15
+
16
+ return (
17
+ <div className="sk-field sk-field--date">
18
+ <label className="sk-field__label">{dateField.label}</label>
19
+ {dateField.description && <p className="sk-field__description">{dateField.description}</p>}
20
+ <input
21
+ className="sk-field__input"
22
+ type={inputType}
23
+ value={(value as string) ?? ''}
24
+ onChange={(e) => setValue(e.target.value)}
25
+ onBlur={touch}
26
+ min={dateField.min}
27
+ max={dateField.max}
28
+ aria-label={dateField.label}
29
+ />
30
+ {errors.length > 0 && (
31
+ <div className="sk-field__errors">
32
+ {errors.map((err, i) => (
33
+ <p key={i} className="sk-field__error">{err}</p>
34
+ ))}
35
+ </div>
36
+ )}
37
+ </div>
38
+ )
39
+ })
@@ -8,6 +8,8 @@ import { IconFieldRenderer } from './icon-field-renderer'
8
8
  import { ArrayFieldRenderer } from './array-field-renderer'
9
9
  import { ObjectFieldRenderer } from './object-field-renderer'
10
10
  import { ImageFieldRenderer } from './image-field-renderer'
11
+ import { DateFieldRenderer } from './date-field-renderer'
12
+ import { ColorFieldRenderer } from './color-field-renderer'
11
13
  import { OverrideFieldRenderer } from './override-field-renderer'
12
14
  import type { createFormStore } from '../stores/form-store'
13
15
 
@@ -34,6 +36,8 @@ const rendererMap: Partial<Record<FieldType, ComponentType<FieldRendererProps>>>
34
36
  array: ArrayFieldRenderer,
35
37
  object: ObjectFieldRenderer,
36
38
  image: ImageFieldRenderer,
39
+ date: DateFieldRenderer,
40
+ color: ColorFieldRenderer,
37
41
  override: OverrideFieldRenderer,
38
42
  }
39
43
 
@@ -791,11 +791,31 @@
791
791
  gap: 8px;
792
792
  }
793
793
 
794
+ .sk-array__items--grid {
795
+ display: grid;
796
+ gap: 12px;
797
+ }
798
+
794
799
  .sk-array__item {
795
800
  background: var(--sk-surface);
796
801
  border: 1px solid var(--sk-border);
797
802
  border-radius: 8px;
798
803
  padding: 12px;
804
+ cursor: grab;
805
+ transition: opacity 0.15s, border-color 0.15s, box-shadow 0.15s;
806
+ }
807
+
808
+ .sk-array__item:active {
809
+ cursor: grabbing;
810
+ }
811
+
812
+ .sk-array__item--dragging {
813
+ opacity: 0.4;
814
+ }
815
+
816
+ .sk-array__item--dragover {
817
+ border-color: var(--sk-accent);
818
+ box-shadow: 0 0 0 2px var(--sk-accent-soft);
799
819
  }
800
820
 
801
821
  .sk-array__item-header {
@@ -805,17 +825,38 @@
805
825
  margin-bottom: 8px;
806
826
  padding-bottom: 8px;
807
827
  border-bottom: 1px solid var(--sk-border);
828
+ gap: 8px;
829
+ }
830
+
831
+ .sk-array__item-drag {
832
+ cursor: grab;
833
+ color: var(--sk-slate);
834
+ font-size: 14px;
835
+ user-select: none;
836
+ line-height: 1;
837
+ opacity: 0.5;
838
+ transition: opacity 0.15s;
839
+ }
840
+
841
+ .sk-array__item-drag:hover {
842
+ opacity: 1;
808
843
  }
809
844
 
810
845
  .sk-array__item-label {
811
846
  font-size: 12px;
812
847
  font-weight: 600;
813
848
  color: var(--sk-slate);
849
+ flex: 1;
850
+ min-width: 0;
851
+ overflow: hidden;
852
+ text-overflow: ellipsis;
853
+ white-space: nowrap;
814
854
  }
815
855
 
816
856
  .sk-array__item-actions {
817
857
  display: flex;
818
858
  gap: 4px;
859
+ flex-shrink: 0;
819
860
  }
820
861
 
821
862
  .sk-array__add {
@@ -831,6 +872,93 @@
831
872
  border-color: var(--sk-accent);
832
873
  }
833
874
 
875
+ /* ---------------------------------------------------------------------------
876
+ Date Field
877
+ --------------------------------------------------------------------------- */
878
+
879
+ .sk-field--date .sk-field__input {
880
+ font-family: 'DM Sans', system-ui, sans-serif;
881
+ color-scheme: light;
882
+ }
883
+
884
+ /* ---------------------------------------------------------------------------
885
+ Color Field
886
+ --------------------------------------------------------------------------- */
887
+
888
+ .sk-color__row {
889
+ display: flex;
890
+ align-items: center;
891
+ gap: 8px;
892
+ }
893
+
894
+ .sk-color__swatch {
895
+ width: 40px;
896
+ height: 40px;
897
+ border-radius: 8px;
898
+ border: 2px solid var(--sk-border);
899
+ cursor: pointer;
900
+ flex-shrink: 0;
901
+ transition: border-color 0.15s;
902
+ }
903
+
904
+ .sk-color__swatch:hover {
905
+ border-color: var(--sk-accent);
906
+ }
907
+
908
+ .sk-color__hex {
909
+ flex: 1;
910
+ font-family: 'DM Mono', monospace;
911
+ text-transform: uppercase;
912
+ }
913
+
914
+ .sk-color__picker {
915
+ margin-top: 8px;
916
+ }
917
+
918
+ .sk-color__native {
919
+ width: 100%;
920
+ height: 40px;
921
+ border: none;
922
+ border-radius: 8px;
923
+ cursor: pointer;
924
+ padding: 0;
925
+ background: none;
926
+ }
927
+
928
+ .sk-color__native::-webkit-color-swatch-wrapper {
929
+ padding: 0;
930
+ }
931
+
932
+ .sk-color__native::-webkit-color-swatch {
933
+ border: 1px solid var(--sk-border);
934
+ border-radius: 8px;
935
+ }
936
+
937
+ .sk-color__presets {
938
+ display: flex;
939
+ flex-wrap: wrap;
940
+ gap: 6px;
941
+ margin-top: 8px;
942
+ }
943
+
944
+ .sk-color__preset {
945
+ width: 28px;
946
+ height: 28px;
947
+ border-radius: 6px;
948
+ border: 2px solid var(--sk-border);
949
+ cursor: pointer;
950
+ transition: border-color 0.15s, transform 0.1s;
951
+ }
952
+
953
+ .sk-color__preset:hover {
954
+ transform: scale(1.15);
955
+ }
956
+
957
+ .sk-color__preset--active {
958
+ border-color: var(--sk-accent);
959
+ box-shadow: 0 0 0 2px var(--sk-accent-soft);
960
+ }
961
+
834
962
  /* ---------------------------------------------------------------------------
835
963
  Object Field
836
964
  --------------------------------------------------------------------------- */