@open-mercato/ui 0.6.1-develop.3256.1.fe3dec2464 → 0.6.1-develop.3276.1.1fcd145078

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.
Files changed (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +46 -129
  3. package/dist/backend/CrudForm.js.map +3 -3
  4. package/dist/backend/DataTable.js +10 -3
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/EmptyState.js +1 -47
  7. package/dist/backend/EmptyState.js.map +2 -2
  8. package/dist/backend/FilterOverlay.js +31 -26
  9. package/dist/backend/FilterOverlay.js.map +2 -2
  10. package/dist/backend/FlashMessages.js +23 -25
  11. package/dist/backend/FlashMessages.js.map +2 -2
  12. package/dist/backend/JsonBuilder.js +4 -2
  13. package/dist/backend/JsonBuilder.js.map +2 -2
  14. package/dist/backend/LogList.js +49 -0
  15. package/dist/backend/LogList.js.map +7 -0
  16. package/dist/backend/crud/CollapsibleGroup.js +13 -3
  17. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  18. package/dist/backend/detail/InlineEditors.js +80 -32
  19. package/dist/backend/detail/InlineEditors.js.map +2 -2
  20. package/dist/backend/inputs/ComboboxInput.js +60 -6
  21. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  22. package/dist/backend/inputs/DatePicker.js +6 -142
  23. package/dist/backend/inputs/DatePicker.js.map +2 -2
  24. package/dist/backend/inputs/DateTimePicker.js +6 -182
  25. package/dist/backend/inputs/DateTimePicker.js.map +2 -2
  26. package/dist/backend/inputs/TagsInput.js +16 -19
  27. package/dist/backend/inputs/TagsInput.js.map +2 -2
  28. package/dist/backend/inputs/TimeInput.js +1 -1
  29. package/dist/backend/inputs/TimeInput.js.map +2 -2
  30. package/dist/backend/inputs/TimePicker.js +83 -72
  31. package/dist/backend/inputs/TimePicker.js.map +2 -2
  32. package/dist/backend/utils/customFieldForms.js +2 -2
  33. package/dist/backend/utils/customFieldForms.js.map +1 -1
  34. package/dist/portal/components/PortalEmptyState.js +13 -9
  35. package/dist/portal/components/PortalEmptyState.js.map +2 -2
  36. package/dist/primitives/accordion.js +137 -0
  37. package/dist/primitives/accordion.js.map +7 -0
  38. package/dist/primitives/alert.js +180 -12
  39. package/dist/primitives/alert.js.map +2 -2
  40. package/dist/primitives/calendar.js +122 -32
  41. package/dist/primitives/calendar.js.map +2 -2
  42. package/dist/primitives/compact-select.js +47 -0
  43. package/dist/primitives/compact-select.js.map +7 -0
  44. package/dist/primitives/counter-input.js +174 -0
  45. package/dist/primitives/counter-input.js.map +7 -0
  46. package/dist/primitives/date-picker-helpers.js +79 -0
  47. package/dist/primitives/date-picker-helpers.js.map +7 -0
  48. package/dist/primitives/date-picker.js +216 -0
  49. package/dist/primitives/date-picker.js.map +7 -0
  50. package/dist/primitives/date-range-picker.js +239 -0
  51. package/dist/primitives/date-range-picker.js.map +7 -0
  52. package/dist/primitives/digit-input.js +157 -0
  53. package/dist/primitives/digit-input.js.map +7 -0
  54. package/dist/primitives/empty-state.js +93 -0
  55. package/dist/primitives/empty-state.js.map +7 -0
  56. package/dist/primitives/inline-input.js +29 -0
  57. package/dist/primitives/inline-input.js.map +7 -0
  58. package/dist/primitives/inline-select.js +41 -0
  59. package/dist/primitives/inline-select.js.map +7 -0
  60. package/dist/primitives/notification-stack.js +116 -0
  61. package/dist/primitives/notification-stack.js.map +7 -0
  62. package/dist/primitives/notification.js +62 -0
  63. package/dist/primitives/notification.js.map +7 -0
  64. package/dist/primitives/rich-editor.js +1230 -0
  65. package/dist/primitives/rich-editor.js.map +7 -0
  66. package/dist/primitives/select.js +1 -0
  67. package/dist/primitives/select.js.map +2 -2
  68. package/dist/primitives/skeleton.js +55 -0
  69. package/dist/primitives/skeleton.js.map +7 -0
  70. package/dist/primitives/tag-input.js +186 -0
  71. package/dist/primitives/tag-input.js.map +7 -0
  72. package/dist/primitives/tag.js +66 -13
  73. package/dist/primitives/tag.js.map +2 -2
  74. package/dist/primitives/time-picker.js +793 -0
  75. package/dist/primitives/time-picker.js.map +7 -0
  76. package/package.json +4 -3
  77. package/src/backend/CrudForm.tsx +61 -113
  78. package/src/backend/DataTable.tsx +17 -5
  79. package/src/backend/EmptyState.tsx +13 -63
  80. package/src/backend/FilterOverlay.tsx +38 -22
  81. package/src/backend/FlashMessages.tsx +23 -24
  82. package/src/backend/JsonBuilder.tsx +4 -2
  83. package/src/backend/LogList.tsx +109 -0
  84. package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
  85. package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
  86. package/src/backend/__tests__/LogList.test.tsx +163 -0
  87. package/src/backend/crud/CollapsibleGroup.tsx +17 -3
  88. package/src/backend/detail/InlineEditors.tsx +78 -25
  89. package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
  90. package/src/backend/inputs/ComboboxInput.tsx +81 -9
  91. package/src/backend/inputs/DatePicker.tsx +28 -155
  92. package/src/backend/inputs/DateTimePicker.tsx +25 -193
  93. package/src/backend/inputs/TagsInput.tsx +10 -13
  94. package/src/backend/inputs/TimeInput.tsx +2 -1
  95. package/src/backend/inputs/TimePicker.tsx +99 -70
  96. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
  97. package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
  98. package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
  99. package/src/backend/utils/customFieldForms.ts +2 -2
  100. package/src/portal/components/PortalEmptyState.tsx +27 -22
  101. package/src/primitives/__tests__/accordion.test.tsx +197 -0
  102. package/src/primitives/__tests__/alert.test.tsx +204 -0
  103. package/src/primitives/__tests__/compact-select.test.tsx +98 -0
  104. package/src/primitives/__tests__/counter-input.test.tsx +165 -0
  105. package/src/primitives/__tests__/date-picker.test.tsx +221 -0
  106. package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
  107. package/src/primitives/__tests__/digit-input.test.tsx +168 -0
  108. package/src/primitives/__tests__/empty-state.test.tsx +154 -0
  109. package/src/primitives/__tests__/inline-input.test.tsx +100 -0
  110. package/src/primitives/__tests__/inline-select.test.tsx +103 -0
  111. package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
  112. package/src/primitives/__tests__/notification.test.tsx +119 -0
  113. package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
  114. package/src/primitives/__tests__/skeleton.test.tsx +76 -0
  115. package/src/primitives/__tests__/tag-input.test.tsx +198 -0
  116. package/src/primitives/__tests__/tag.test.tsx +123 -0
  117. package/src/primitives/__tests__/time-picker.test.tsx +392 -0
  118. package/src/primitives/accordion.tsx +225 -0
  119. package/src/primitives/alert.tsx +234 -24
  120. package/src/primitives/calendar.tsx +146 -38
  121. package/src/primitives/compact-select.tsx +59 -0
  122. package/src/primitives/counter-input.tsx +211 -0
  123. package/src/primitives/date-picker-helpers.ts +96 -0
  124. package/src/primitives/date-picker.tsx +290 -0
  125. package/src/primitives/date-range-picker.tsx +281 -0
  126. package/src/primitives/digit-input.tsx +208 -0
  127. package/src/primitives/empty-state.tsx +140 -0
  128. package/src/primitives/inline-input.tsx +55 -0
  129. package/src/primitives/inline-select.tsx +74 -0
  130. package/src/primitives/notification-stack.tsx +185 -0
  131. package/src/primitives/notification.tsx +136 -0
  132. package/src/primitives/rich-editor.tsx +1578 -0
  133. package/src/primitives/select.tsx +1 -0
  134. package/src/primitives/skeleton.tsx +70 -0
  135. package/src/primitives/tag-input.tsx +223 -0
  136. package/src/primitives/tag.tsx +69 -10
  137. package/src/primitives/time-picker.tsx +1059 -0
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen } from '@testing-library/react'
7
+ import {
8
+ Accordion,
9
+ AccordionContent,
10
+ AccordionItem,
11
+ AccordionTrigger,
12
+ } from '../accordion'
13
+
14
+ function renderBasic(
15
+ props: Partial<React.ComponentProps<typeof AccordionTrigger>> = {},
16
+ itemProps: Partial<React.ComponentProps<typeof AccordionItem>> = {},
17
+ ) {
18
+ return render(
19
+ <Accordion type="single" collapsible>
20
+ <AccordionItem value="one" {...itemProps}>
21
+ <AccordionTrigger {...props}>How do I update my account?</AccordionTrigger>
22
+ <AccordionContent>Visit Settings → Profile to edit your details.</AccordionContent>
23
+ </AccordionItem>
24
+ </Accordion>,
25
+ )
26
+ }
27
+
28
+ describe('Accordion primitive', () => {
29
+ it('renders the trigger label', () => {
30
+ renderBasic()
31
+ expect(screen.getByText('How do I update my account?')).toBeInTheDocument()
32
+ })
33
+
34
+ it('starts closed by default (single + collapsible)', () => {
35
+ const { container } = renderBasic()
36
+ const item = container.querySelector('[data-slot="accordion-item"]')
37
+ expect(item).toHaveAttribute('data-state', 'closed')
38
+ })
39
+
40
+ it('opens the item when the trigger is clicked', () => {
41
+ const { container } = renderBasic()
42
+ fireEvent.click(screen.getByRole('button', { name: /How do I update/ }))
43
+ expect(container.querySelector('[data-slot="accordion-item"]')).toHaveAttribute('data-state', 'open')
44
+ })
45
+
46
+ it('closes the item on a second click when collapsible', () => {
47
+ const { container } = renderBasic()
48
+ const trigger = screen.getByRole('button', { name: /How do I update/ })
49
+ fireEvent.click(trigger)
50
+ fireEvent.click(trigger)
51
+ expect(container.querySelector('[data-slot="accordion-item"]')).toHaveAttribute('data-state', 'closed')
52
+ })
53
+
54
+ it('only opens one item at a time with type="single"', () => {
55
+ const { container } = render(
56
+ <Accordion type="single" collapsible>
57
+ <AccordionItem value="a">
58
+ <AccordionTrigger>A</AccordionTrigger>
59
+ <AccordionContent>A body</AccordionContent>
60
+ </AccordionItem>
61
+ <AccordionItem value="b">
62
+ <AccordionTrigger>B</AccordionTrigger>
63
+ <AccordionContent>B body</AccordionContent>
64
+ </AccordionItem>
65
+ </Accordion>,
66
+ )
67
+ fireEvent.click(screen.getByRole('button', { name: 'A' }))
68
+ fireEvent.click(screen.getByRole('button', { name: 'B' }))
69
+ const items = container.querySelectorAll('[data-slot="accordion-item"]')
70
+ expect(items[0]).toHaveAttribute('data-state', 'closed')
71
+ expect(items[1]).toHaveAttribute('data-state', 'open')
72
+ })
73
+
74
+ it('allows multiple items to stay open with type="multiple"', () => {
75
+ const { container } = render(
76
+ <Accordion type="multiple">
77
+ <AccordionItem value="a">
78
+ <AccordionTrigger>A</AccordionTrigger>
79
+ <AccordionContent>A body</AccordionContent>
80
+ </AccordionItem>
81
+ <AccordionItem value="b">
82
+ <AccordionTrigger>B</AccordionTrigger>
83
+ <AccordionContent>B body</AccordionContent>
84
+ </AccordionItem>
85
+ </Accordion>,
86
+ )
87
+ fireEvent.click(screen.getByRole('button', { name: 'A' }))
88
+ fireEvent.click(screen.getByRole('button', { name: 'B' }))
89
+ const items = container.querySelectorAll('[data-slot="accordion-item"]')
90
+ expect(items[0]).toHaveAttribute('data-state', 'open')
91
+ expect(items[1]).toHaveAttribute('data-state', 'open')
92
+ })
93
+
94
+ it('marks the root slots with data-slot attributes', () => {
95
+ const { container } = renderBasic()
96
+ expect(container.querySelector('[data-slot="accordion-item"]')).not.toBeNull()
97
+ expect(container.querySelector('[data-slot="accordion-trigger"]')).not.toBeNull()
98
+ expect(container.querySelector('[data-slot="accordion-content"]')).not.toBeNull()
99
+ })
100
+
101
+ it('renders the Plus/Minus indicator by default (plus-minus triggerIcon)', () => {
102
+ const { container } = renderBasic()
103
+ const indicator = container.querySelector('[data-slot="accordion-trigger-indicator"]')
104
+ expect(indicator).not.toBeNull()
105
+ // Plus and Minus are both present; CSS toggles visibility via data-state.
106
+ expect(indicator?.querySelectorAll('svg').length).toBe(2)
107
+ })
108
+
109
+ it('renders a single ChevronDown when triggerIcon="chevron"', () => {
110
+ const { container } = renderBasic({ triggerIcon: 'chevron' })
111
+ const indicator = container.querySelector('[data-slot="accordion-trigger-indicator"]')
112
+ expect(indicator?.querySelectorAll('svg').length).toBe(1)
113
+ })
114
+
115
+ it('omits the indicator entirely when triggerIcon="none"', () => {
116
+ const { container } = renderBasic({ triggerIcon: 'none' })
117
+ expect(container.querySelector('[data-slot="accordion-trigger-indicator"]')).toBeNull()
118
+ })
119
+
120
+ it('renders a custom indicator node when provided (overrides triggerIcon)', () => {
121
+ const { container } = renderBasic({
122
+ triggerIcon: 'chevron',
123
+ indicator: <span data-testid="custom-indicator">42</span>,
124
+ })
125
+ expect(screen.getByTestId('custom-indicator')).toBeInTheDocument()
126
+ // No Chevron rendered when indicator override is supplied.
127
+ const indicator = container.querySelector('[data-slot="accordion-trigger-indicator"]')
128
+ expect(indicator?.querySelector('svg')).toBeNull()
129
+ })
130
+
131
+ it('renders leftIcon in a dedicated slot before the label', () => {
132
+ const { container } = renderBasic({ leftIcon: <span data-testid="left-icon" /> })
133
+ expect(container.querySelector('[data-slot="accordion-trigger-left-icon"]')).not.toBeNull()
134
+ expect(screen.getByTestId('left-icon')).toBeInTheDocument()
135
+ })
136
+
137
+ it('suppresses leftIcon when iconPosition="start" (indicator owns the leading slot)', () => {
138
+ const { container } = renderBasic({
139
+ leftIcon: <span data-testid="left-icon" />,
140
+ iconPosition: 'start',
141
+ })
142
+ expect(container.querySelector('[data-slot="accordion-trigger-left-icon"]')).toBeNull()
143
+ expect(screen.queryByTestId('left-icon')).toBeNull()
144
+ })
145
+
146
+ it('places the indicator at the start of the trigger when iconPosition="start"', () => {
147
+ const { container } = renderBasic({ iconPosition: 'start' })
148
+ const trigger = container.querySelector('[data-slot="accordion-trigger"]')!
149
+ const indicator = trigger.querySelector('[data-slot="accordion-trigger-indicator"]')
150
+ const label = trigger.querySelector('[data-slot="accordion-trigger-label"]')
151
+ expect(indicator).not.toBeNull()
152
+ // Indicator precedes the label in DOM order.
153
+ expect(indicator?.compareDocumentPosition(label!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
154
+ })
155
+
156
+ it('applies card variant classes by default (border + shadow + bg-card)', () => {
157
+ const { container } = renderBasic()
158
+ const item = container.querySelector('[data-slot="accordion-item"]')!
159
+ expect(item).toHaveClass('border-border')
160
+ expect(item).toHaveClass('bg-card')
161
+ expect(item).toHaveClass('shadow-xs')
162
+ })
163
+
164
+ it('applies borderless variant classes when variant="borderless"', () => {
165
+ const { container } = renderBasic({}, { variant: 'borderless' })
166
+ const item = container.querySelector('[data-slot="accordion-item"]')!
167
+ expect(item).toHaveClass('border-transparent')
168
+ expect(item).toHaveClass('bg-transparent')
169
+ expect(item).toHaveClass('shadow-none')
170
+ })
171
+
172
+ it('exposes --accordion-indent on the item with a Tailwind has-[] promoter for the leftIcon case', () => {
173
+ const { container } = renderBasic({ leftIcon: <span /> })
174
+ const item = container.querySelector('[data-slot="accordion-item"]')!
175
+ // We can't assert the resolved CSS variable value in jsdom (no real CSS engine),
176
+ // so we assert the Tailwind utility that flips the variable is on the item.
177
+ expect(item.className).toContain('[--accordion-indent:14px]')
178
+ expect(item.className).toContain('has-[[data-slot=accordion-trigger-left-icon]]:[--accordion-indent:44px]')
179
+ })
180
+
181
+ it('forwards refs to the underlying Radix Item / Trigger / Content nodes', () => {
182
+ const itemRef = React.createRef<HTMLDivElement>()
183
+ const triggerRef = React.createRef<HTMLButtonElement>()
184
+ const contentRef = React.createRef<HTMLDivElement>()
185
+ render(
186
+ <Accordion type="single" collapsible defaultValue="one">
187
+ <AccordionItem ref={itemRef} value="one">
188
+ <AccordionTrigger ref={triggerRef}>Title</AccordionTrigger>
189
+ <AccordionContent ref={contentRef}>Body</AccordionContent>
190
+ </AccordionItem>
191
+ </Accordion>,
192
+ )
193
+ expect(itemRef.current).not.toBeNull()
194
+ expect(triggerRef.current?.tagName).toBe('BUTTON')
195
+ expect(contentRef.current).not.toBeNull()
196
+ })
197
+ })
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen } from '@testing-library/react'
7
+ import { Alert, AlertDescription, AlertTitle } from '../alert'
8
+
9
+ describe('Alert primitive', () => {
10
+ it('renders with role="alert"', () => {
11
+ render(<Alert>Hello</Alert>)
12
+ expect(screen.getByRole('alert')).toBeInTheDocument()
13
+ })
14
+
15
+ it('defaults to status="information" + style="light" + size="sm"', () => {
16
+ const { container } = render(<Alert>Body</Alert>)
17
+ const alert = container.querySelector('[data-slot="alert"]')
18
+ expect(alert).toHaveAttribute('data-status', 'information')
19
+ expect(alert).toHaveAttribute('data-style', 'light')
20
+ expect(alert).toHaveClass('min-h-9')
21
+ expect(alert).toHaveClass('rounded-lg')
22
+ })
23
+
24
+ describe('Figma tokens — status × style matrix', () => {
25
+ it('error / filled: bg-status-error-icon + text-white', () => {
26
+ const { container } = render(<Alert status="error" style="filled">Oops</Alert>)
27
+ const alert = container.querySelector('[data-slot="alert"]')
28
+ expect(alert).toHaveClass('bg-status-error-icon')
29
+ expect(alert).toHaveClass('text-white')
30
+ })
31
+
32
+ it('error / light: bg-status-error-border + text-status-error-text (Figma #fecaca)', () => {
33
+ const { container } = render(<Alert status="error" style="light">Oops</Alert>)
34
+ const alert = container.querySelector('[data-slot="alert"]')
35
+ expect(alert).toHaveClass('bg-status-error-border')
36
+ expect(alert).toHaveClass('text-foreground')
37
+ })
38
+
39
+ it('error / lighter: bg-status-error-bg + text-status-error-text (Figma #fef2f2)', () => {
40
+ const { container } = render(<Alert status="error" style="lighter">Oops</Alert>)
41
+ const alert = container.querySelector('[data-slot="alert"]')
42
+ expect(alert).toHaveClass('bg-status-error-bg')
43
+ expect(alert).toHaveClass('text-foreground')
44
+ })
45
+
46
+ it('stroke style: bg-background + text-foreground + border-border + shadow-lg', () => {
47
+ const { container } = render(<Alert status="warning" style="stroke">Heads up</Alert>)
48
+ const alert = container.querySelector('[data-slot="alert"]')
49
+ expect(alert).toHaveClass('bg-background')
50
+ expect(alert).toHaveClass('text-foreground')
51
+ expect(alert).toHaveClass('border-border')
52
+ expect(alert).toHaveClass('shadow-lg')
53
+ })
54
+
55
+ it('feature status maps to status-neutral tokens (NOT brand-violet)', () => {
56
+ const { container } = render(<Alert status="feature" style="light">New release</Alert>)
57
+ const alert = container.querySelector('[data-slot="alert"]')
58
+ expect(alert).toHaveClass('bg-status-neutral-border')
59
+ expect(alert).toHaveClass('text-foreground')
60
+ expect(alert).not.toHaveClass('bg-brand-violet')
61
+ })
62
+
63
+ it('feature / filled: bg-status-neutral-icon + text-white', () => {
64
+ const { container } = render(<Alert status="feature" style="filled">Hi</Alert>)
65
+ const alert = container.querySelector('[data-slot="alert"]')
66
+ expect(alert).toHaveClass('bg-status-neutral-icon')
67
+ expect(alert).toHaveClass('text-white')
68
+ })
69
+ })
70
+
71
+ describe('Sizes', () => {
72
+ it('size="xs" applies min-h-8 + rounded-lg + text-xs', () => {
73
+ const { container } = render(<Alert size="xs">xs</Alert>)
74
+ const alert = container.querySelector('[data-slot="alert"]')
75
+ expect(alert).toHaveClass('min-h-8')
76
+ expect(alert).toHaveClass('rounded-lg')
77
+ expect(alert).toHaveClass('text-xs')
78
+ })
79
+
80
+ it('size="sm" applies min-h-9 + rounded-lg + text-sm (Figma Paragraph/Small)', () => {
81
+ const { container } = render(<Alert size="sm">sm</Alert>)
82
+ const alert = container.querySelector('[data-slot="alert"]')
83
+ expect(alert).toHaveClass('min-h-9')
84
+ expect(alert).toHaveClass('rounded-lg')
85
+ expect(alert).toHaveClass('text-sm')
86
+ })
87
+
88
+ it('size="default" applies rounded-xl + larger padding + no min height', () => {
89
+ const { container } = render(<Alert size="default">default</Alert>)
90
+ const alert = container.querySelector('[data-slot="alert"]')
91
+ expect(alert).toHaveClass('rounded-xl')
92
+ expect(alert).toHaveClass('text-sm')
93
+ })
94
+ })
95
+
96
+ describe('Icon badge (non-filled styles)', () => {
97
+ it('renders a badge wrap with status-x-icon bg + white icon for light style', () => {
98
+ const { container } = render(<Alert status="success" style="light">Saved</Alert>)
99
+ const badge = container.querySelector('[data-slot="alert-icon-badge"]')
100
+ expect(badge).not.toBeNull()
101
+ expect(badge).toHaveAttribute('data-status', 'success')
102
+ expect(badge).toHaveClass('bg-status-success-icon')
103
+ expect(badge).toHaveClass('rounded-full')
104
+ expect(badge).toHaveClass('text-white')
105
+ })
106
+
107
+ it('renders a badge wrap for lighter style too', () => {
108
+ const { container } = render(<Alert status="information" style="lighter">FYI</Alert>)
109
+ expect(container.querySelector('[data-slot="alert-icon-badge"]')).not.toBeNull()
110
+ })
111
+
112
+ it('renders a badge wrap for stroke style too', () => {
113
+ const { container } = render(<Alert status="error" style="stroke">Oops</Alert>)
114
+ expect(container.querySelector('[data-slot="alert-icon-badge"]')).not.toBeNull()
115
+ })
116
+
117
+ it('does NOT render a badge wrap for filled style — plain icon instead', () => {
118
+ const { container } = render(<Alert status="error" style="filled">Oops</Alert>)
119
+ expect(container.querySelector('[data-slot="alert-icon-badge"]')).toBeNull()
120
+ expect(container.querySelector('[data-slot="alert-icon"]')).not.toBeNull()
121
+ })
122
+
123
+ it('hides the icon entirely when showIcon is false', () => {
124
+ const { container } = render(
125
+ <Alert status="success" showIcon={false}>Done</Alert>,
126
+ )
127
+ expect(container.querySelector('[data-slot="alert-icon-badge"]')).toBeNull()
128
+ expect(container.querySelector('[data-slot="alert-icon"]')).toBeNull()
129
+ })
130
+
131
+ it('respects a custom icon override inside the badge', () => {
132
+ const Custom = () => <svg data-testid="custom-icon" aria-hidden="true" />
133
+ render(<Alert status="information" style="light" icon={<Custom />}>Info</Alert>)
134
+ expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
135
+ })
136
+ })
137
+
138
+ describe('Action slot + dismiss', () => {
139
+ it('renders an inline action slot to the right of the description', () => {
140
+ render(
141
+ <Alert action={<button data-testid="action-link">Undo</button>}>Saved</Alert>,
142
+ )
143
+ expect(screen.getByTestId('action-link')).toBeInTheDocument()
144
+ })
145
+
146
+ it('renders a dismiss button when dismissible is true', () => {
147
+ render(<Alert dismissible onDismiss={() => {}}>Closable</Alert>)
148
+ expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
149
+ })
150
+
151
+ it('fires onDismiss when the dismiss button is clicked', () => {
152
+ const onDismiss = jest.fn()
153
+ render(<Alert dismissible onDismiss={onDismiss}>Closable</Alert>)
154
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
155
+ expect(onDismiss).toHaveBeenCalledTimes(1)
156
+ })
157
+
158
+ it('honors a custom dismissAriaLabel for i18n', () => {
159
+ render(
160
+ <Alert dismissible onDismiss={() => {}} dismissAriaLabel="Zamknij">
161
+ Closable
162
+ </Alert>,
163
+ )
164
+ expect(screen.getByRole('button', { name: 'Zamknij' })).toBeInTheDocument()
165
+ })
166
+ })
167
+
168
+ it('AlertTitle and AlertDescription compose inside the body slot', () => {
169
+ render(
170
+ <Alert status="success">
171
+ <AlertTitle>Saved</AlertTitle>
172
+ <AlertDescription>Your changes were saved.</AlertDescription>
173
+ </Alert>,
174
+ )
175
+ expect(screen.getByText('Saved').tagName).toBe('H5')
176
+ expect(screen.getByText('Your changes were saved.').tagName).toBe('P')
177
+ })
178
+
179
+ describe('legacy variant prop (BC)', () => {
180
+ it('maps variant="destructive" to status="error" with the new light default', () => {
181
+ const { container } = render(<Alert variant="destructive">Oops</Alert>)
182
+ const alert = container.querySelector('[data-slot="alert"]')
183
+ expect(alert).toHaveAttribute('data-status', 'error')
184
+ expect(alert).toHaveAttribute('data-style', 'light')
185
+ expect(alert).toHaveClass('bg-status-error-border')
186
+ expect(alert).toHaveClass('text-foreground')
187
+ })
188
+
189
+ it('maps variant="info" to status="information"', () => {
190
+ const { container } = render(<Alert variant="info">FYI</Alert>)
191
+ expect(container.querySelector('[data-slot="alert"]')).toHaveAttribute('data-status', 'information')
192
+ })
193
+
194
+ it('maps variant="default" to status="information"', () => {
195
+ const { container } = render(<Alert variant="default">Hello</Alert>)
196
+ expect(container.querySelector('[data-slot="alert"]')).toHaveAttribute('data-status', 'information')
197
+ })
198
+
199
+ it('explicit status overrides the legacy variant prop', () => {
200
+ const { container } = render(<Alert variant="destructive" status="success">Saved</Alert>)
201
+ expect(container.querySelector('[data-slot="alert"]')).toHaveAttribute('data-status', 'success')
202
+ })
203
+ })
204
+ })
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { render, screen } from '@testing-library/react'
7
+ import {
8
+ CompactSelectTrigger,
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectValue,
13
+ } from '../compact-select'
14
+
15
+ function renderCompactSelect(
16
+ props: Partial<React.ComponentProps<typeof CompactSelectTrigger>> = {},
17
+ defaultValue: string = 'all',
18
+ ) {
19
+ return render(
20
+ <Select defaultValue={defaultValue}>
21
+ <CompactSelectTrigger aria-label="View" {...props}>
22
+ <SelectValue />
23
+ </CompactSelectTrigger>
24
+ <SelectContent>
25
+ <SelectItem value="all">All</SelectItem>
26
+ <SelectItem value="open">Open</SelectItem>
27
+ </SelectContent>
28
+ </Select>,
29
+ )
30
+ }
31
+
32
+ describe('CompactSelect primitive', () => {
33
+ it('renders the trigger at h-7 (xs) regardless of explicit size attempts', () => {
34
+ const { container } = renderCompactSelect()
35
+ const trigger = container.querySelector('[data-slot="compact-select-trigger"]')
36
+ expect(trigger).not.toBeNull()
37
+ expect(trigger).toHaveClass('h-7')
38
+ })
39
+
40
+ it('uses the smaller px-2 text-xs styling of the xs size variant', () => {
41
+ const { container } = renderCompactSelect()
42
+ const trigger = container.querySelector('[data-slot="compact-select-trigger"]')!
43
+ expect(trigger).toHaveClass('px-2')
44
+ expect(trigger).toHaveClass('text-xs')
45
+ })
46
+
47
+ it('renders the triggerLabel prefix when provided', () => {
48
+ renderCompactSelect({ triggerLabel: 'View:' })
49
+ expect(screen.getByText('View:')).toBeInTheDocument()
50
+ })
51
+
52
+ it('omits the triggerLabel slot when no label is provided', () => {
53
+ const { container } = renderCompactSelect()
54
+ expect(container.querySelector('[data-slot="compact-select-trigger-label"]')).toBeNull()
55
+ })
56
+
57
+ it('marks the trigger label slot with data-slot for styling hooks', () => {
58
+ const { container } = renderCompactSelect({ triggerLabel: 'Sort:' })
59
+ const slot = container.querySelector('[data-slot="compact-select-trigger-label"]')
60
+ expect(slot).not.toBeNull()
61
+ expect(slot?.textContent).toBe('Sort:')
62
+ })
63
+
64
+ it('forwards the trigger to a ref consumer', () => {
65
+ const ref = React.createRef<HTMLButtonElement>()
66
+ render(
67
+ <Select defaultValue="a">
68
+ <CompactSelectTrigger ref={ref} aria-label="Pick">
69
+ <SelectValue />
70
+ </CompactSelectTrigger>
71
+ <SelectContent>
72
+ <SelectItem value="a">A</SelectItem>
73
+ </SelectContent>
74
+ </Select>,
75
+ )
76
+ expect(ref.current).not.toBeNull()
77
+ expect(ref.current?.tagName).toBe('BUTTON')
78
+ })
79
+
80
+ it('applies className alongside the size variant', () => {
81
+ const { container } = renderCompactSelect({ className: 'rounded-full border-brand-violet' })
82
+ const trigger = container.querySelector('[data-slot="compact-select-trigger"]')
83
+ expect(trigger).toHaveClass('rounded-full')
84
+ expect(trigger).toHaveClass('border-brand-violet')
85
+ expect(trigger).toHaveClass('h-7')
86
+ })
87
+
88
+ it('forwards SelectTrigger ARIA props to the underlying Radix trigger', () => {
89
+ renderCompactSelect({ 'aria-label': 'Period selector' })
90
+ expect(screen.getByRole('combobox', { name: 'Period selector' })).toBeInTheDocument()
91
+ })
92
+
93
+ it('renders aria-invalid styling when aria-invalid is set', () => {
94
+ const { container } = renderCompactSelect({ 'aria-invalid': true })
95
+ const trigger = container.querySelector('[aria-invalid="true"]')
96
+ expect(trigger).not.toBeNull()
97
+ })
98
+ })
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, render, screen } from '@testing-library/react'
7
+ import { CounterInput } from '../counter-input'
8
+
9
+ describe('CounterInput primitive', () => {
10
+ it('renders the value passed via the value prop', () => {
11
+ render(<CounterInput value={5} onChange={() => {}} />)
12
+ const input = screen.getByRole('spinbutton') as HTMLInputElement
13
+ expect(input.value).toBe('5')
14
+ })
15
+
16
+ it('renders an empty string when value is null', () => {
17
+ render(<CounterInput value={null} onChange={() => {}} />)
18
+ const input = screen.getByRole('spinbutton') as HTMLInputElement
19
+ expect(input.value).toBe('')
20
+ })
21
+
22
+ it('increments by step when + is clicked', () => {
23
+ const onChange = jest.fn()
24
+ render(<CounterInput value={3} onChange={onChange} step={2} />)
25
+ fireEvent.click(screen.getByRole('button', { name: 'Increase' }))
26
+ expect(onChange).toHaveBeenCalledWith(5)
27
+ })
28
+
29
+ it('decrements by step when - is clicked', () => {
30
+ const onChange = jest.fn()
31
+ render(<CounterInput value={10} onChange={onChange} step={3} />)
32
+ fireEvent.click(screen.getByRole('button', { name: 'Decrease' }))
33
+ expect(onChange).toHaveBeenCalledWith(7)
34
+ })
35
+
36
+ it('defaults step to 1', () => {
37
+ const onChange = jest.fn()
38
+ render(<CounterInput value={0} onChange={onChange} />)
39
+ fireEvent.click(screen.getByRole('button', { name: 'Increase' }))
40
+ expect(onChange).toHaveBeenCalledWith(1)
41
+ })
42
+
43
+ it('clamps to max when incrementing past max', () => {
44
+ const onChange = jest.fn()
45
+ render(<CounterInput value={9} onChange={onChange} max={10} step={5} />)
46
+ fireEvent.click(screen.getByRole('button', { name: 'Increase' }))
47
+ expect(onChange).toHaveBeenCalledWith(10)
48
+ })
49
+
50
+ it('clamps to min when decrementing below min', () => {
51
+ const onChange = jest.fn()
52
+ render(<CounterInput value={2} onChange={onChange} min={0} step={5} />)
53
+ fireEvent.click(screen.getByRole('button', { name: 'Decrease' }))
54
+ expect(onChange).toHaveBeenCalledWith(0)
55
+ })
56
+
57
+ it('disables the increment button when value is at max', () => {
58
+ render(<CounterInput value={10} onChange={() => {}} max={10} />)
59
+ expect(screen.getByRole('button', { name: 'Increase' })).toBeDisabled()
60
+ expect(screen.getByRole('button', { name: 'Decrease' })).not.toBeDisabled()
61
+ })
62
+
63
+ it('disables the decrement button when value is at min', () => {
64
+ render(<CounterInput value={1} onChange={() => {}} min={1} />)
65
+ expect(screen.getByRole('button', { name: 'Decrease' })).toBeDisabled()
66
+ expect(screen.getByRole('button', { name: 'Increase' })).not.toBeDisabled()
67
+ })
68
+
69
+ it('disables both buttons when disabled prop is set', () => {
70
+ render(<CounterInput value={5} onChange={() => {}} disabled />)
71
+ expect(screen.getByRole('button', { name: 'Decrease' })).toBeDisabled()
72
+ expect(screen.getByRole('button', { name: 'Increase' })).toBeDisabled()
73
+ expect(screen.getByRole('spinbutton')).toBeDisabled()
74
+ })
75
+
76
+ it('commits valid number typed directly', () => {
77
+ const onChange = jest.fn()
78
+ render(<CounterInput value={0} onChange={onChange} />)
79
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '42' } })
80
+ expect(onChange).toHaveBeenCalledWith(42)
81
+ })
82
+
83
+ it('emits null when input is cleared', () => {
84
+ const onChange = jest.fn()
85
+ render(<CounterInput value={5} onChange={onChange} />)
86
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '' } })
87
+ expect(onChange).toHaveBeenCalledWith(null)
88
+ })
89
+
90
+ it('clamps typed value to max', () => {
91
+ const onChange = jest.fn()
92
+ render(<CounterInput value={0} onChange={onChange} max={100} />)
93
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '999' } })
94
+ expect(onChange).toHaveBeenCalledWith(100)
95
+ })
96
+
97
+ it('clamps typed value to min', () => {
98
+ const onChange = jest.fn()
99
+ render(<CounterInput value={0} onChange={onChange} min={1} />)
100
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '-5' } })
101
+ expect(onChange).toHaveBeenCalledWith(1)
102
+ })
103
+
104
+ it('formats output to precision decimals', () => {
105
+ const onChange = jest.fn()
106
+ render(<CounterInput value={1} onChange={onChange} precision={2} step={0.5} />)
107
+ const input = screen.getByRole('spinbutton') as HTMLInputElement
108
+ expect(input.value).toBe('1.00')
109
+ fireEvent.click(screen.getByRole('button', { name: 'Increase' }))
110
+ expect(onChange).toHaveBeenCalledWith(1.5)
111
+ })
112
+
113
+ it('ArrowUp increments by step', () => {
114
+ const onChange = jest.fn()
115
+ render(<CounterInput value={2} onChange={onChange} step={3} />)
116
+ fireEvent.keyDown(screen.getByRole('spinbutton'), { key: 'ArrowUp' })
117
+ expect(onChange).toHaveBeenCalledWith(5)
118
+ })
119
+
120
+ it('ArrowDown decrements by step', () => {
121
+ const onChange = jest.fn()
122
+ render(<CounterInput value={5} onChange={onChange} step={2} />)
123
+ fireEvent.keyDown(screen.getByRole('spinbutton'), { key: 'ArrowDown' })
124
+ expect(onChange).toHaveBeenCalledWith(3)
125
+ })
126
+
127
+ it('uncontrolled mode keeps internal state and emits onChange', () => {
128
+ const onChange = jest.fn()
129
+ render(<CounterInput onChange={onChange} />)
130
+ fireEvent.click(screen.getByRole('button', { name: 'Increase' }))
131
+ expect(onChange).toHaveBeenCalledWith(1)
132
+ const input = screen.getByRole('spinbutton') as HTMLInputElement
133
+ expect(input.value).toBe('1')
134
+ })
135
+
136
+ it('honors custom decrement / increment aria labels', () => {
137
+ render(
138
+ <CounterInput
139
+ value={0}
140
+ onChange={() => {}}
141
+ decrementAriaLabel="Zmniejsz"
142
+ incrementAriaLabel="Zwiększ"
143
+ />,
144
+ )
145
+ expect(screen.getByRole('button', { name: 'Zmniejsz' })).toBeInTheDocument()
146
+ expect(screen.getByRole('button', { name: 'Zwiększ' })).toBeInTheDocument()
147
+ })
148
+
149
+ it('renders aria-invalid styling when aria-invalid is set', () => {
150
+ const { container } = render(
151
+ <CounterInput value={5} onChange={() => {}} aria-invalid />,
152
+ )
153
+ const input = container.querySelector('input')
154
+ expect(input).toHaveAttribute('aria-invalid', 'true')
155
+ })
156
+
157
+ it('applies size variants to the wrapper', () => {
158
+ const { container, rerender } = render(<CounterInput value={1} onChange={() => {}} size="sm" />)
159
+ expect(container.querySelector('[data-slot="counter-input"]')).toHaveClass('h-8')
160
+ rerender(<CounterInput value={1} onChange={() => {}} size="default" />)
161
+ expect(container.querySelector('[data-slot="counter-input"]')).toHaveClass('h-9')
162
+ rerender(<CounterInput value={1} onChange={() => {}} size="lg" />)
163
+ expect(container.querySelector('[data-slot="counter-input"]')).toHaveClass('h-10')
164
+ })
165
+ })