@moontra/moonui 3.2.0 → 3.4.0
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/dist/chunk-VXDFZPDA.global.js +33 -0
- package/dist/{chunk-X5ULQZNC.global.js.map → chunk-VXDFZPDA.global.js.map} +1 -1
- package/dist/index.d.mts +47 -3
- package/dist/index.d.ts +47 -3
- package/dist/index.global.js +41 -19
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +216 -21
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +208 -22
- package/dist/index.mjs.map +1 -1
- package/dist/lib/theme.global.js +30 -6
- package/dist/lib/theme.global.js.map +1 -1
- package/package.json +2 -1
- package/src/components/ui/__tests__/button-group.test.tsx +183 -0
- package/src/components/ui/__tests__/checkbox.test.tsx +16 -12
- package/src/components/ui/__tests__/color-picker.test.tsx +147 -331
- package/src/components/ui/__tests__/radio-group.test.tsx +60 -0
- package/src/components/ui/__tests__/slider.test.tsx +96 -0
- package/src/components/ui/__tests__/toggle-group.test.tsx +323 -0
- package/src/components/ui/button-group.tsx +125 -0
- package/src/components/ui/checkbox.tsx +20 -18
- package/src/components/ui/color-picker.tsx +12 -0
- package/src/components/ui/index.ts +30 -0
- package/src/components/ui/radio-group.tsx +47 -8
- package/src/components/ui/slider.tsx +56 -2
- package/src/components/ui/toggle-group.tsx +97 -0
- package/dist/chunk-X5ULQZNC.global.js +0 -33
|
@@ -490,4 +490,100 @@ describe('Slider Component', () => {
|
|
|
490
490
|
expect(thumbs).toHaveLength(1)
|
|
491
491
|
})
|
|
492
492
|
})
|
|
493
|
+
|
|
494
|
+
// Regresyon koruması: thumb'lar `role="slider"` + `tabIndex=0` taşıyıp
|
|
495
|
+
// odaklanabiliyordu ama hiçbir klavye handler'ı yoktu — ARIA sözleşmesi
|
|
496
|
+
// slider vaat ediyor, bileşen yalnızca fareyle çalışıyordu (WCAG 2.1.1).
|
|
497
|
+
describe('Klavye ile değer değiştirme', () => {
|
|
498
|
+
it('ArrowRight ve ArrowUp değeri step kadar artırır', () => {
|
|
499
|
+
const onValueChange = jest.fn()
|
|
500
|
+
render(
|
|
501
|
+
<Slider defaultValue={[50]} min={0} max={100} step={1} onValueChange={onValueChange} />
|
|
502
|
+
)
|
|
503
|
+
const thumb = screen.getByRole('slider')
|
|
504
|
+
|
|
505
|
+
fireEvent.keyDown(thumb, { key: 'ArrowRight' })
|
|
506
|
+
expect(onValueChange).toHaveBeenLastCalledWith([51])
|
|
507
|
+
|
|
508
|
+
fireEvent.keyDown(thumb, { key: 'ArrowUp' })
|
|
509
|
+
expect(onValueChange).toHaveBeenLastCalledWith([52])
|
|
510
|
+
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '52')
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('ArrowLeft ve ArrowDown değeri step kadar azaltır', () => {
|
|
514
|
+
const onValueChange = jest.fn()
|
|
515
|
+
render(
|
|
516
|
+
<Slider defaultValue={[50]} min={0} max={100} step={5} onValueChange={onValueChange} />
|
|
517
|
+
)
|
|
518
|
+
const thumb = screen.getByRole('slider')
|
|
519
|
+
|
|
520
|
+
fireEvent.keyDown(thumb, { key: 'ArrowLeft' })
|
|
521
|
+
expect(onValueChange).toHaveBeenLastCalledWith([45])
|
|
522
|
+
|
|
523
|
+
fireEvent.keyDown(thumb, { key: 'ArrowDown' })
|
|
524
|
+
expect(onValueChange).toHaveBeenLastCalledWith([40])
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('Home ve End uç değerlere taşır', () => {
|
|
528
|
+
const onValueChange = jest.fn()
|
|
529
|
+
render(
|
|
530
|
+
<Slider defaultValue={[50]} min={10} max={90} step={1} onValueChange={onValueChange} />
|
|
531
|
+
)
|
|
532
|
+
const thumb = screen.getByRole('slider')
|
|
533
|
+
|
|
534
|
+
fireEvent.keyDown(thumb, { key: 'Home' })
|
|
535
|
+
expect(onValueChange).toHaveBeenLastCalledWith([10])
|
|
536
|
+
|
|
537
|
+
fireEvent.keyDown(screen.getByRole('slider'), { key: 'End' })
|
|
538
|
+
expect(onValueChange).toHaveBeenLastCalledWith([90])
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('PageUp ve PageDown 10x step uygular', () => {
|
|
542
|
+
const onValueChange = jest.fn()
|
|
543
|
+
render(
|
|
544
|
+
<Slider defaultValue={[50]} min={0} max={100} step={2} onValueChange={onValueChange} />
|
|
545
|
+
)
|
|
546
|
+
fireEvent.keyDown(screen.getByRole('slider'), { key: 'PageUp' })
|
|
547
|
+
expect(onValueChange).toHaveBeenLastCalledWith([70])
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('min/max sınırlarını aşmaz', () => {
|
|
551
|
+
const onValueChange = jest.fn()
|
|
552
|
+
render(
|
|
553
|
+
<Slider defaultValue={[100]} min={0} max={100} step={1} onValueChange={onValueChange} />
|
|
554
|
+
)
|
|
555
|
+
fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' })
|
|
556
|
+
expect(onValueChange).toHaveBeenLastCalledWith([100])
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
it('disabled iken hiçbir tuş değeri değiştirmez', () => {
|
|
560
|
+
const onValueChange = jest.fn()
|
|
561
|
+
render(
|
|
562
|
+
<Slider defaultValue={[50]} min={0} max={100} disabled onValueChange={onValueChange} />
|
|
563
|
+
)
|
|
564
|
+
fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' })
|
|
565
|
+
expect(onValueChange).not.toHaveBeenCalled()
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('çoklu thumb komşusunun ötesine geçmez', () => {
|
|
569
|
+
const onValueChange = jest.fn()
|
|
570
|
+
render(
|
|
571
|
+
<Slider defaultValue={[40, 41]} min={0} max={100} step={1} onValueChange={onValueChange} />
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
// Soldaki thumb sağdakine (41) kadar çıkabilir, ötesine geçemez.
|
|
575
|
+
fireEvent.keyDown(screen.getAllByRole('slider')[0], { key: 'ArrowRight' })
|
|
576
|
+
expect(onValueChange).toHaveBeenLastCalledWith([41, 41])
|
|
577
|
+
|
|
578
|
+
fireEvent.keyDown(screen.getAllByRole('slider')[0], { key: 'ArrowRight' })
|
|
579
|
+
expect(onValueChange).toHaveBeenLastCalledWith([41, 41])
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
it('eşlenmemiş tuşlar değeri değiştirmez', () => {
|
|
583
|
+
const onValueChange = jest.fn()
|
|
584
|
+
render(<Slider defaultValue={[50]} onValueChange={onValueChange} />)
|
|
585
|
+
fireEvent.keyDown(screen.getByRole('slider'), { key: 'a' })
|
|
586
|
+
expect(onValueChange).not.toHaveBeenCalled()
|
|
587
|
+
})
|
|
588
|
+
})
|
|
493
589
|
})
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import '@testing-library/jest-dom'
|
|
4
|
+
|
|
5
|
+
import { ToggleGroup, ToggleGroupItem } from '../toggle-group'
|
|
6
|
+
|
|
7
|
+
describe('ToggleGroup Components', () => {
|
|
8
|
+
describe('Rendering', () => {
|
|
9
|
+
it('renders group with items', () => {
|
|
10
|
+
render(
|
|
11
|
+
<ToggleGroup type="single" data-testid="group">
|
|
12
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
13
|
+
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
|
14
|
+
</ToggleGroup>
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const group = screen.getByTestId('group')
|
|
18
|
+
expect(group).toBeInTheDocument()
|
|
19
|
+
expect(screen.getByText('A')).toBeInTheDocument()
|
|
20
|
+
expect(screen.getByText('B')).toBeInTheDocument()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('applies base + custom className on the root', () => {
|
|
24
|
+
render(
|
|
25
|
+
<ToggleGroup type="single" className="custom-group" data-testid="group">
|
|
26
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
27
|
+
</ToggleGroup>
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const group = screen.getByTestId('group')
|
|
31
|
+
// moonui-theme + FREE token/layout base sınıfları korunur
|
|
32
|
+
expect(group).toHaveClass('moonui-theme', 'inline-flex', 'items-center', 'gap-1')
|
|
33
|
+
expect(group).toHaveClass('custom-group')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('applies custom className on an item alongside toggleVariants', () => {
|
|
37
|
+
render(
|
|
38
|
+
<ToggleGroup type="single">
|
|
39
|
+
<ToggleGroupItem value="a" className="custom-item">
|
|
40
|
+
A
|
|
41
|
+
</ToggleGroupItem>
|
|
42
|
+
</ToggleGroup>
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const item = screen.getByText('A')
|
|
46
|
+
expect(item).toHaveClass('custom-item')
|
|
47
|
+
// toggleVariants'tan gelen aktif-durum token sınıfı
|
|
48
|
+
expect(item).toHaveClass('data-[state=on]:bg-accent')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('forwards ref on the group', () => {
|
|
52
|
+
const ref = React.createRef<HTMLDivElement>()
|
|
53
|
+
render(
|
|
54
|
+
<ToggleGroup type="single" ref={ref}>
|
|
55
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
56
|
+
</ToggleGroup>
|
|
57
|
+
)
|
|
58
|
+
expect(ref.current).toBeInstanceOf(HTMLDivElement)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('forwards ref on the item', () => {
|
|
62
|
+
const ref = React.createRef<HTMLButtonElement>()
|
|
63
|
+
render(
|
|
64
|
+
<ToggleGroup type="single">
|
|
65
|
+
<ToggleGroupItem value="a" ref={ref}>
|
|
66
|
+
A
|
|
67
|
+
</ToggleGroupItem>
|
|
68
|
+
</ToggleGroup>
|
|
69
|
+
)
|
|
70
|
+
expect(ref.current).toBeInstanceOf(HTMLButtonElement)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('maintains displayNames', () => {
|
|
74
|
+
expect(ToggleGroup.displayName).toBe('ToggleGroup')
|
|
75
|
+
expect(ToggleGroupItem.displayName).toBe('ToggleGroupItem')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('Single selection (type="single")', () => {
|
|
80
|
+
it('marks exactly one item active via defaultValue', () => {
|
|
81
|
+
render(
|
|
82
|
+
<ToggleGroup type="single" defaultValue="b">
|
|
83
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
84
|
+
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
|
85
|
+
</ToggleGroup>
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
expect(screen.getByText('A')).toHaveAttribute('data-state', 'off')
|
|
89
|
+
expect(screen.getByText('B')).toHaveAttribute('data-state', 'on')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('respects controlled value', () => {
|
|
93
|
+
render(
|
|
94
|
+
<ToggleGroup type="single" value="a">
|
|
95
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
96
|
+
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
|
97
|
+
</ToggleGroup>
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
expect(screen.getByText('A')).toHaveAttribute('data-state', 'on')
|
|
101
|
+
expect(screen.getByText('B')).toHaveAttribute('data-state', 'off')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('fires onValueChange with a string on click', () => {
|
|
105
|
+
const onValueChange = jest.fn()
|
|
106
|
+
render(
|
|
107
|
+
<ToggleGroup type="single" onValueChange={onValueChange}>
|
|
108
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
109
|
+
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
|
110
|
+
</ToggleGroup>
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
fireEvent.click(screen.getByText('B'))
|
|
114
|
+
expect(onValueChange).toHaveBeenCalledWith('b')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('Multiple selection (type="multiple")', () => {
|
|
119
|
+
it('marks multiple items active via defaultValue', () => {
|
|
120
|
+
render(
|
|
121
|
+
<ToggleGroup type="multiple" defaultValue={['a', 'b']}>
|
|
122
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
123
|
+
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
|
124
|
+
<ToggleGroupItem value="c">C</ToggleGroupItem>
|
|
125
|
+
</ToggleGroup>
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
expect(screen.getByText('A')).toHaveAttribute('data-state', 'on')
|
|
129
|
+
expect(screen.getByText('B')).toHaveAttribute('data-state', 'on')
|
|
130
|
+
expect(screen.getByText('C')).toHaveAttribute('data-state', 'off')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('fires onValueChange with an array on click', () => {
|
|
134
|
+
const onValueChange = jest.fn()
|
|
135
|
+
render(
|
|
136
|
+
<ToggleGroup type="multiple" defaultValue={['a']} onValueChange={onValueChange}>
|
|
137
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
138
|
+
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
|
139
|
+
</ToggleGroup>
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
fireEvent.click(screen.getByText('B'))
|
|
143
|
+
expect(onValueChange).toHaveBeenCalledWith(['a', 'b'])
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('Variant / size context propagation', () => {
|
|
148
|
+
it('propagates group variant + size to items', () => {
|
|
149
|
+
render(
|
|
150
|
+
<ToggleGroup type="single" variant="outline" size="lg">
|
|
151
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
152
|
+
</ToggleGroup>
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const item = screen.getByText('A')
|
|
156
|
+
// outline variant + lg size (toggleVariants ile birebir)
|
|
157
|
+
expect(item).toHaveClass('border', 'border-input')
|
|
158
|
+
expect(item).toHaveClass('h-11', 'px-5')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('uses item prop when the group provides no variant/size', () => {
|
|
162
|
+
render(
|
|
163
|
+
<ToggleGroup type="single">
|
|
164
|
+
<ToggleGroupItem value="a" variant="outline" size="sm">
|
|
165
|
+
A
|
|
166
|
+
</ToggleGroupItem>
|
|
167
|
+
</ToggleGroup>
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const item = screen.getByText('A')
|
|
171
|
+
// context tanımsız → item prop devreye girer
|
|
172
|
+
expect(item).toHaveClass('border', 'border-input')
|
|
173
|
+
expect(item).toHaveClass('h-9', 'px-2.5')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('group variant overrides item variant (context wins)', () => {
|
|
177
|
+
render(
|
|
178
|
+
<ToggleGroup type="single" variant="outline">
|
|
179
|
+
<ToggleGroupItem value="a" variant="default">
|
|
180
|
+
A
|
|
181
|
+
</ToggleGroupItem>
|
|
182
|
+
</ToggleGroup>
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const item = screen.getByText('A')
|
|
186
|
+
// context.variant ?? props.variant → outline kazanır
|
|
187
|
+
expect(item).toHaveClass('border', 'border-input')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('falls back to default variant/size when nothing is set', () => {
|
|
191
|
+
render(
|
|
192
|
+
<ToggleGroup type="single">
|
|
193
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
194
|
+
</ToggleGroup>
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
const item = screen.getByText('A')
|
|
198
|
+
// default variant: bg-transparent, default size: h-10 px-3
|
|
199
|
+
expect(item).toHaveClass('bg-transparent', 'h-10', 'px-3')
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('Disabled state', () => {
|
|
204
|
+
it('disables all items when the group is disabled', () => {
|
|
205
|
+
render(
|
|
206
|
+
<ToggleGroup type="single" disabled>
|
|
207
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
208
|
+
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
|
209
|
+
</ToggleGroup>
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
expect(screen.getByText('A')).toBeDisabled()
|
|
213
|
+
expect(screen.getByText('B')).toBeDisabled()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('disables an individual item', () => {
|
|
217
|
+
const onValueChange = jest.fn()
|
|
218
|
+
render(
|
|
219
|
+
<ToggleGroup type="single" onValueChange={onValueChange}>
|
|
220
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
221
|
+
<ToggleGroupItem value="b" disabled>
|
|
222
|
+
B
|
|
223
|
+
</ToggleGroupItem>
|
|
224
|
+
</ToggleGroup>
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
const disabledItem = screen.getByText('B')
|
|
228
|
+
expect(disabledItem).toBeDisabled()
|
|
229
|
+
fireEvent.click(disabledItem)
|
|
230
|
+
expect(onValueChange).not.toHaveBeenCalled()
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('Accessibility (Radix-provided)', () => {
|
|
235
|
+
it('single group exposes radiogroup + radio roles', () => {
|
|
236
|
+
render(
|
|
237
|
+
<ToggleGroup type="single" data-testid="group">
|
|
238
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
239
|
+
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
|
240
|
+
</ToggleGroup>
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
expect(screen.getByTestId('group')).toHaveAttribute('role', 'radiogroup')
|
|
244
|
+
expect(screen.getAllByRole('radio')).toHaveLength(2)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('single active item reflects aria-checked', () => {
|
|
248
|
+
render(
|
|
249
|
+
<ToggleGroup type="single" defaultValue="a">
|
|
250
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
251
|
+
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
|
252
|
+
</ToggleGroup>
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
expect(screen.getByText('A')).toHaveAttribute('aria-checked', 'true')
|
|
256
|
+
expect(screen.getByText('B')).toHaveAttribute('aria-checked', 'false')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('multiple group exposes toolbar role + aria-pressed items', () => {
|
|
260
|
+
render(
|
|
261
|
+
<ToggleGroup type="multiple" defaultValue={['a']} data-testid="group">
|
|
262
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
263
|
+
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
|
264
|
+
</ToggleGroup>
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
expect(screen.getByTestId('group')).toHaveAttribute('role', 'toolbar')
|
|
268
|
+
expect(screen.getByText('A')).toHaveAttribute('aria-pressed', 'true')
|
|
269
|
+
expect(screen.getByText('B')).toHaveAttribute('aria-pressed', 'false')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('renders items as focusable buttons with Radix roving tabindex', () => {
|
|
273
|
+
render(
|
|
274
|
+
<ToggleGroup type="single" defaultValue="a">
|
|
275
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
276
|
+
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
|
277
|
+
</ToggleGroup>
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
const first = screen.getByText('A')
|
|
281
|
+
const second = screen.getByText('B')
|
|
282
|
+
|
|
283
|
+
// Radix RovingFocusGroup her item'a bir tabindex atar (roving nav altyapısı).
|
|
284
|
+
// Ok-tuşuyla odak taşıma gerçek tarayıcıda Radix tarafından sağlanır;
|
|
285
|
+
// jsdom odak olaylarını tam simüle etmediği için burada odaklanabilirlik doğrulanır.
|
|
286
|
+
expect(first).toHaveAttribute('tabindex')
|
|
287
|
+
expect(second).toHaveAttribute('tabindex')
|
|
288
|
+
|
|
289
|
+
first.focus()
|
|
290
|
+
expect(first).toHaveFocus()
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
describe('Orientation', () => {
|
|
295
|
+
it('varsayılan/yatay yerleşimde taban sınıflar korunur', () => {
|
|
296
|
+
render(
|
|
297
|
+
<ToggleGroup type="single" data-testid="group">
|
|
298
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
299
|
+
</ToggleGroup>
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
const group = screen.getByTestId('group')
|
|
303
|
+
expect(group).toHaveClass('inline-flex', 'items-center', 'justify-center')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('orientation="vertical" dikey yerleşim sınıflarını devreye alır', () => {
|
|
307
|
+
render(
|
|
308
|
+
<ToggleGroup type="single" orientation="vertical" data-testid="group">
|
|
309
|
+
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
|
310
|
+
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
|
311
|
+
</ToggleGroup>
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
const group = screen.getByTestId('group')
|
|
315
|
+
// Radix roving-focus köke data-orientation yazar; dikey sınıflar buna bağlıdır.
|
|
316
|
+
// Eskiden kök className sabitti: `orientation` tip yüzeyinde vardı ama
|
|
317
|
+
// yerleşime hiç yansımıyordu (kardeş button-group bunu doğru yapıyor).
|
|
318
|
+
expect(group).toHaveAttribute('data-orientation', 'vertical')
|
|
319
|
+
expect(group.className).toContain('data-[orientation=vertical]:flex-col')
|
|
320
|
+
expect(group.className).toContain('data-[orientation=vertical]:items-stretch')
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
import { Button, type ButtonProps } from "./button";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Premium ButtonGroup Component
|
|
10
|
+
*
|
|
11
|
+
* Bitişik (segmentli) veya boşluklu buton grupları için yüksek kaliteli,
|
|
12
|
+
* erişilebilir bir kapsayıcı bileşen. Yatay/dikey yerleşim, bitişik kenar
|
|
13
|
+
* radius birleştirme ve çocuk `<Button>`'lara size/variant yayılımı sunar.
|
|
14
|
+
*
|
|
15
|
+
* Tamamen token-tabanlıdır: kenarlık ve köşe yuvarlama değerleri mevcut Button
|
|
16
|
+
* token'larından gelir; hardcoded renk YOKtur.
|
|
17
|
+
*/
|
|
18
|
+
const buttonGroupVariants = cva(
|
|
19
|
+
// Temel kapsayıcı: inline-flex; çocuklar akış içinde hizalanır
|
|
20
|
+
"moonui-theme inline-flex",
|
|
21
|
+
{
|
|
22
|
+
variants: {
|
|
23
|
+
orientation: {
|
|
24
|
+
// Yatay: butonlar dikeyde ortalanır (içerik-genişliği korunur)
|
|
25
|
+
horizontal: "flex-row items-center",
|
|
26
|
+
// Dikey: butonlar aynı genişliğe uzar (items-stretch) → kenarlar hizalanır
|
|
27
|
+
vertical: "flex-col items-stretch",
|
|
28
|
+
},
|
|
29
|
+
attached: {
|
|
30
|
+
// Bitişik: boşluk yok; radius birleştirme compoundVariants ile uygulanır
|
|
31
|
+
true: "",
|
|
32
|
+
// Boşluklu: normal aralık, radius korunur
|
|
33
|
+
false: "gap-2",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
compoundVariants: [
|
|
37
|
+
{
|
|
38
|
+
// Yatay + bitişik: bitişik yatay kenarlar düzleşir, -ml-px ile kenarlık örtüşür
|
|
39
|
+
orientation: "horizontal",
|
|
40
|
+
attached: true,
|
|
41
|
+
class: [
|
|
42
|
+
"[&>*:not(:first-child)]:rounded-l-none",
|
|
43
|
+
"[&>*:not(:last-child)]:rounded-r-none",
|
|
44
|
+
"[&>*:not(:first-child)]:-ml-px",
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
// Dikey + bitişik: bitişik dikey kenarlar düzleşir, -mt-px ile kenarlık örtüşür
|
|
49
|
+
orientation: "vertical",
|
|
50
|
+
attached: true,
|
|
51
|
+
class: [
|
|
52
|
+
"[&>*:not(:first-child)]:rounded-t-none",
|
|
53
|
+
"[&>*:not(:last-child)]:rounded-b-none",
|
|
54
|
+
"[&>*:not(:first-child)]:-mt-px",
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
defaultVariants: {
|
|
59
|
+
orientation: "horizontal",
|
|
60
|
+
attached: true,
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// ButtonGroup component props
|
|
66
|
+
export interface ButtonGroupProps
|
|
67
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
68
|
+
VariantProps<typeof buttonGroupVariants> {
|
|
69
|
+
/** Çocuk Button'lara yayılacak boyut (yalnızca çocuk kendi size'ını vermemişse) */
|
|
70
|
+
size?: ButtonProps["size"];
|
|
71
|
+
/** Çocuk Button'lara yayılacak varyant (yalnızca çocuk kendi variant'ını vermemişse) */
|
|
72
|
+
variant?: ButtonProps["variant"];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Premium ButtonGroup Component
|
|
77
|
+
*
|
|
78
|
+
* @param props - ButtonGroup bileşeni özellikleri
|
|
79
|
+
* @param props.orientation - Yerleşim yönü ("horizontal" | "vertical"), varsayılan "horizontal"
|
|
80
|
+
* @param props.attached - Bitişik (segmentli) mi yoksa boşluklu mu, varsayılan true
|
|
81
|
+
* @param props.size - Çocuk Button'lara yayılacak boyut (opsiyonel)
|
|
82
|
+
* @param props.variant - Çocuk Button'lara yayılacak varyant (opsiyonel)
|
|
83
|
+
*/
|
|
84
|
+
const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(
|
|
85
|
+
(
|
|
86
|
+
{ className, orientation, attached, size, variant, children, ...props },
|
|
87
|
+
ref
|
|
88
|
+
) => {
|
|
89
|
+
// size/variant yalnızca en az biri tanımlıysa çocuklara enjekte edilir
|
|
90
|
+
const shouldPropagate = size !== undefined || variant !== undefined;
|
|
91
|
+
|
|
92
|
+
const enhancedChildren = shouldPropagate
|
|
93
|
+
? React.Children.map(children, (child) => {
|
|
94
|
+
// Geçersiz element (string/number/null) veya Button olmayan çocuklar
|
|
95
|
+
// GRACEFUL geçilir — geçersiz prop enjekte edilmez
|
|
96
|
+
if (!React.isValidElement(child) || child.type !== Button) {
|
|
97
|
+
return child;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const childProps = child.props as ButtonProps;
|
|
101
|
+
|
|
102
|
+
// Çocuğun kendi prop'u önceliklidir (child.props.size ?? size)
|
|
103
|
+
return React.cloneElement(child as React.ReactElement<ButtonProps>, {
|
|
104
|
+
size: childProps.size ?? size,
|
|
105
|
+
variant: childProps.variant ?? variant,
|
|
106
|
+
});
|
|
107
|
+
})
|
|
108
|
+
: children;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div
|
|
112
|
+
ref={ref}
|
|
113
|
+
role="group"
|
|
114
|
+
className={cn(buttonGroupVariants({ orientation, attached }), className)}
|
|
115
|
+
{...props}
|
|
116
|
+
>
|
|
117
|
+
{enhancedChildren}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
ButtonGroup.displayName = "ButtonGroup";
|
|
124
|
+
|
|
125
|
+
export { ButtonGroup, buttonGroupVariants };
|
|
@@ -15,14 +15,14 @@ import { cn } from "../../lib/utils";
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
const checkboxVariants = cva(
|
|
18
|
-
"peer shrink-0 border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:text-primary-foreground",
|
|
18
|
+
"peer shrink-0 border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:text-primary-foreground data-[state=indeterminate]:text-primary-foreground",
|
|
19
19
|
{
|
|
20
20
|
variants: {
|
|
21
21
|
variant: {
|
|
22
|
-
default: "border-border bg-background data-[state=checked]:bg-primary data-[state=checked]:border-primary",
|
|
23
|
-
outline: "border-border bg-transparent data-[state=checked]:bg-primary data-[state=checked]:border-primary",
|
|
24
|
-
muted: "border-border bg-accent data-[state=checked]:bg-primary data-[state=checked]:border-primary",
|
|
25
|
-
ghost: "border-transparent bg-transparent hover:bg-accent data-[state=checked]:bg-primary data-[state=checked]:border-primary",
|
|
22
|
+
default: "border-border bg-background data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary",
|
|
23
|
+
outline: "border-border bg-transparent data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary",
|
|
24
|
+
muted: "border-border bg-accent data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary",
|
|
25
|
+
ghost: "border-transparent bg-transparent hover:bg-accent data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary",
|
|
26
26
|
},
|
|
27
27
|
size: {
|
|
28
28
|
sm: "h-3.5 w-3.5",
|
|
@@ -80,16 +80,18 @@ const Checkbox = React.forwardRef<
|
|
|
80
80
|
checked,
|
|
81
81
|
...props
|
|
82
82
|
}, ref) => {
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
// Radix'in `CheckedState` tipi: boolean | "indeterminate".
|
|
84
|
+
// "indeterminate" iletildiğinde Radix kökte `data-state="indeterminate"` +
|
|
85
|
+
// `aria-checked="mixed"` üretir ve Indicator'ı render eder — Minus ikonu ancak
|
|
86
|
+
// bu durumda görünür olur.
|
|
87
|
+
//
|
|
88
|
+
// Eskiden burada `false` iletiliyordu: kök hep `data-state="unchecked"` kalıyor,
|
|
89
|
+
// Radix Indicator unchecked durumda render edilmediği için Minus hiç görünmüyor
|
|
90
|
+
// ve ekran okuyucu "kısmen seçili" durumunu duyuramıyordu.
|
|
91
|
+
//
|
|
92
|
+
// `checked` undefined ise olduğu gibi geçirilir — uncontrolled kullanım korunur.
|
|
93
|
+
const effectiveChecked = indeterminate ? "indeterminate" : checked;
|
|
89
94
|
|
|
90
|
-
// Checked state override, indeterminate olduğunda
|
|
91
|
-
const effectiveChecked = isIndeterminate ? false : checked;
|
|
92
|
-
|
|
93
95
|
return (
|
|
94
96
|
<CheckboxPrimitive.Root
|
|
95
97
|
ref={ref}
|
|
@@ -103,7 +105,7 @@ const Checkbox = React.forwardRef<
|
|
|
103
105
|
animation === "bounce" && "data-[state=checked]:animate-bounce"
|
|
104
106
|
)}
|
|
105
107
|
>
|
|
106
|
-
{
|
|
108
|
+
{indeterminate ? (
|
|
107
109
|
<Minus className="h-[65%] w-[65%]" />
|
|
108
110
|
) : icon ? (
|
|
109
111
|
icon
|
|
@@ -117,7 +119,7 @@ const Checkbox = React.forwardRef<
|
|
|
117
119
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
|
118
120
|
|
|
119
121
|
// CheckboxGroup bileşeni
|
|
120
|
-
interface CheckboxGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
122
|
+
export interface CheckboxGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
121
123
|
/**
|
|
122
124
|
* Grup içi yerleşim - dikey veya yatay
|
|
123
125
|
*/
|
|
@@ -154,7 +156,7 @@ const CheckboxGroup = React.forwardRef<HTMLDivElement, CheckboxGroupProps>(
|
|
|
154
156
|
CheckboxGroup.displayName = "CheckboxGroup";
|
|
155
157
|
|
|
156
158
|
// CheckboxLabel bileşeni
|
|
157
|
-
interface CheckboxLabelProps extends React.HTMLAttributes<HTMLLabelElement> {
|
|
159
|
+
export interface CheckboxLabelProps extends React.HTMLAttributes<HTMLLabelElement> {
|
|
158
160
|
/**
|
|
159
161
|
* Checkbox bileşeni için HTML id
|
|
160
162
|
*/
|
|
@@ -195,7 +197,7 @@ const CheckboxLabel = React.forwardRef<HTMLLabelElement, CheckboxLabelProps>(
|
|
|
195
197
|
CheckboxLabel.displayName = "CheckboxLabel";
|
|
196
198
|
|
|
197
199
|
// Checkbox ve Label içeren bileşen
|
|
198
|
-
interface CheckboxWithLabelProps extends CheckboxProps {
|
|
200
|
+
export interface CheckboxWithLabelProps extends CheckboxProps {
|
|
199
201
|
/**
|
|
200
202
|
* Label içeriği
|
|
201
203
|
*/
|
|
@@ -528,8 +528,14 @@ export function ColorPicker({
|
|
|
528
528
|
{presets.map((presetColor) => (
|
|
529
529
|
<button
|
|
530
530
|
key={presetColor}
|
|
531
|
+
type="button"
|
|
532
|
+
// Swatch'ın tek içeriği arka plan rengi — erişilebilir ad
|
|
533
|
+
// olmadan ekran okuyucu yalnızca "button" diye okur.
|
|
534
|
+
aria-label={`Select color ${presetColor}`}
|
|
535
|
+
aria-pressed={color === presetColor}
|
|
531
536
|
className={cn(
|
|
532
537
|
"h-7 w-7 rounded border-2 transition-all hover:scale-110",
|
|
538
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
533
539
|
color === presetColor
|
|
534
540
|
? "border-primary"
|
|
535
541
|
: "border-transparent"
|
|
@@ -578,8 +584,14 @@ export function SimpleColorPicker({
|
|
|
578
584
|
{colors.map((color) => (
|
|
579
585
|
<button
|
|
580
586
|
key={color}
|
|
587
|
+
type="button"
|
|
588
|
+
// Swatch'ın tek içeriği arka plan rengi — erişilebilir ad olmadan
|
|
589
|
+
// ekran okuyucu yalnızca "button" diye okur.
|
|
590
|
+
aria-label={`Select color ${color}`}
|
|
591
|
+
aria-pressed={value === color}
|
|
581
592
|
className={cn(
|
|
582
593
|
"rounded-full border-2 transition-all hover:scale-110",
|
|
594
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
583
595
|
sizeClasses[size],
|
|
584
596
|
value === color ? "border-primary ring-2 ring-primary/20" : "border-transparent"
|
|
585
597
|
)}
|