@open-mercato/ui 0.6.7-develop.6828.1.ab1620a63e → 0.6.7-develop.6842.1.8cd8a8883c

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 (46) hide show
  1. package/dist/backend/JsonBuilder.js +3 -3
  2. package/dist/backend/JsonBuilder.js.map +2 -2
  3. package/dist/backend/ProfileDropdown.js +2 -1
  4. package/dist/backend/ProfileDropdown.js.map +2 -2
  5. package/dist/backend/RowActions.js +2 -2
  6. package/dist/backend/RowActions.js.map +2 -2
  7. package/dist/backend/confirm-dialog/ConfirmDialog.js +1 -1
  8. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  9. package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
  10. package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
  11. package/dist/backend/detail/AttachmentsSection.js +1 -1
  12. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  13. package/dist/backend/detail/NotesSection.js +1 -1
  14. package/dist/backend/detail/NotesSection.js.map +2 -2
  15. package/dist/backend/detail/TagsSection.js +1 -1
  16. package/dist/backend/detail/TagsSection.js.map +2 -2
  17. package/dist/backend/forms/FormHeader.js +1 -2
  18. package/dist/backend/forms/FormHeader.js.map +2 -2
  19. package/dist/backend/schedule/ScheduleAgenda.js +3 -3
  20. package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
  21. package/dist/backend/schedule/ScheduleGrid.js +3 -3
  22. package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
  23. package/dist/backend/utils/apiCall.js +21 -1
  24. package/dist/backend/utils/apiCall.js.map +2 -2
  25. package/dist/frontend/LanguageSwitcher.js +2 -1
  26. package/dist/frontend/LanguageSwitcher.js.map +2 -2
  27. package/dist/primitives/button.js +9 -4
  28. package/dist/primitives/button.js.map +2 -2
  29. package/package.json +4 -4
  30. package/src/backend/JsonBuilder.tsx +3 -3
  31. package/src/backend/ProfileDropdown.tsx +1 -0
  32. package/src/backend/RowActions.tsx +2 -2
  33. package/src/backend/confirm-dialog/ConfirmDialog.tsx +1 -1
  34. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +35 -0
  35. package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
  36. package/src/backend/detail/AttachmentsSection.tsx +1 -1
  37. package/src/backend/detail/NotesSection.tsx +1 -1
  38. package/src/backend/detail/TagsSection.tsx +1 -1
  39. package/src/backend/forms/FormHeader.tsx +1 -2
  40. package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
  41. package/src/backend/schedule/ScheduleGrid.tsx +3 -3
  42. package/src/backend/utils/__tests__/apiCall.test.ts +61 -0
  43. package/src/backend/utils/apiCall.ts +29 -1
  44. package/src/frontend/LanguageSwitcher.tsx +1 -0
  45. package/src/primitives/__tests__/button.test.tsx +70 -0
  46. package/src/primitives/button.tsx +10 -4
@@ -61,6 +61,41 @@ describe('ConfirmDialog', () => {
61
61
  expect(dialog.parentElement).toBe(document.body)
62
62
  })
63
63
 
64
+ it('renders the destructive confirmation as the solid variant', () => {
65
+ renderWithProviders(
66
+ <ConfirmDialog
67
+ open
68
+ variant="destructive"
69
+ onOpenChange={() => undefined}
70
+ onConfirm={() => undefined}
71
+ title="Delete customer?"
72
+ confirmText="Delete"
73
+ cancelText="Cancel"
74
+ />,
75
+ )
76
+
77
+ const classNames = screen.getByRole('button', { name: 'Delete' }).className.split(/\s+/)
78
+ expect(classNames).toContain('bg-destructive')
79
+ expect(classNames).toContain('text-white')
80
+ })
81
+
82
+ it('keeps the default confirmation on the primary variant', () => {
83
+ renderWithProviders(
84
+ <ConfirmDialog
85
+ open
86
+ onOpenChange={() => undefined}
87
+ onConfirm={() => undefined}
88
+ title="Publish article?"
89
+ confirmText="Publish"
90
+ cancelText="Cancel"
91
+ />,
92
+ )
93
+
94
+ const classNames = screen.getByRole('button', { name: 'Publish' }).className.split(/\s+/)
95
+ expect(classNames).toContain('bg-primary')
96
+ expect(classNames).not.toContain('bg-destructive')
97
+ })
98
+
64
99
  it('closes only the confirmation that owns an Escape event', () => {
65
100
  const firstOpenChange = jest.fn()
66
101
  const secondOpenChange = jest.fn()
@@ -38,7 +38,7 @@ export function AttachmentDeleteDialog({ open, onOpenChange, fileName, onConfirm
38
38
  <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isDeleting}>
39
39
  {t('attachments.library.metadata.cancel', 'Cancel')}
40
40
  </Button>
41
- <Button type="button" variant="destructive" onClick={onConfirm} disabled={isDeleting}>
41
+ <Button type="button" variant="destructive-solid" onClick={onConfirm} disabled={isDeleting}>
42
42
  {t('attachments.library.actions.delete', 'Delete')}
43
43
  </Button>
44
44
  </DialogFooter>
@@ -256,7 +256,7 @@ function AttachmentsSectionImpl({
256
256
  </div>
257
257
  )}
258
258
 
259
- {error ? <p className="text-xs font-medium text-red-600">{error}</p> : null}
259
+ {error ? <p className="text-xs font-medium text-status-error-text">{error}</p> : null}
260
260
 
261
261
  {loading ? (
262
262
  <div className="text-sm text-muted-foreground">{t('attachments.library.loading', 'Loading attachments…')}</div>
@@ -1256,7 +1256,7 @@ function NotesSectionImpl<C = unknown>({
1256
1256
  editorWrapperClassName="w-full rounded-md border border-muted-foreground/20 bg-background p-2"
1257
1257
  remarkPlugins={markdownPlugins}
1258
1258
  />
1259
- {contentError ? <p className="text-xs text-red-600">{contentError}</p> : null}
1259
+ {contentError ? <p className="text-xs text-status-error-text">{contentError}</p> : null}
1260
1260
  <div className="flex flex-wrap items-center gap-2">
1261
1261
  <Button type="button" size="sm" onClick={handleContentSave} disabled={contentSavingId === note.id}>
1262
1262
  {contentSavingId === note.id ? (
@@ -387,7 +387,7 @@ function TagsSectionImpl({
387
387
  loadSuggestions={loadSuggestions}
388
388
  autoFocus={!autoSave}
389
389
  />
390
- {error ? <p className="text-xs text-red-600">{error}</p> : null}
390
+ {error ? <p className="text-xs text-status-error-text">{error}</p> : null}
391
391
  {autoSave ? null : (
392
392
  <div className="flex items-center gap-2 mt-3 mb-2">
393
393
  <Button type="button" size="sm" onClick={handleSave} disabled={saving || isSubmitting}>
@@ -181,11 +181,10 @@ function DetailHeader({
181
181
  {onDelete ? (
182
182
  <Button
183
183
  type="button"
184
- variant="outline"
184
+ variant="destructive"
185
185
  size="sm"
186
186
  onClick={onDelete}
187
187
  disabled={isDeleting}
188
- className="text-red-600 border-red-200 hover:bg-red-50 rounded"
189
188
  >
190
189
  {isDeleting ? (
191
190
  <Loader2 className="size-4 mr-2 animate-spin" />
@@ -57,9 +57,9 @@ function getStatusLabel(status: ScheduleItem['status'], t: (key: string, fallbac
57
57
  }
58
58
 
59
59
  function getKindStyles(kind: ScheduleItem['kind']): string {
60
- if (kind === 'event') return 'border-blue-500/40 bg-blue-500/10 text-blue-950'
61
- if (kind === 'exception') return 'border-amber-500/40 bg-amber-500/10 text-amber-950'
62
- return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-950'
60
+ if (kind === 'event') return 'border-status-info-border bg-status-info-bg text-status-info-text'
61
+ if (kind === 'exception') return 'border-status-warning-border bg-status-warning-bg text-status-warning-text'
62
+ return 'border-status-success-border bg-status-success-bg text-status-success-text'
63
63
  }
64
64
 
65
65
  export type ScheduleAgendaProps = {
@@ -57,9 +57,9 @@ function getStatusLabel(status: ScheduleItem['status'], t: (key: string, fallbac
57
57
  }
58
58
 
59
59
  function getKindStyles(kind: ScheduleItem['kind']): string {
60
- if (kind === 'event') return 'border-blue-500/40 bg-blue-500/10 text-blue-950'
61
- if (kind === 'exception') return 'border-amber-500/40 bg-amber-500/10 text-amber-950'
62
- return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-950'
60
+ if (kind === 'event') return 'border-status-info-border bg-status-info-bg text-status-info-text'
61
+ if (kind === 'exception') return 'border-status-warning-border bg-status-warning-bg text-status-warning-text'
62
+ return 'border-status-success-border bg-status-success-bg text-status-success-text'
63
63
  }
64
64
 
65
65
  export type ScheduleGridProps = {
@@ -107,3 +107,64 @@ describe('readApiResultOrThrow', () => {
107
107
  expect(result).toBeNull()
108
108
  })
109
109
  })
110
+
111
+ describe('aborted requests', () => {
112
+ beforeEach(() => {
113
+ jest.resetAllMocks()
114
+ })
115
+
116
+ function createAbortedBodyResponse(): Response {
117
+ const abortError = new Error('The operation was aborted.')
118
+ abortError.name = 'AbortError'
119
+ const response = {
120
+ ok: true,
121
+ status: 200,
122
+ headers: new Map<string, string>(),
123
+ text: jest.fn(async () => { throw abortError }),
124
+ clone: () => response,
125
+ } as unknown as Response
126
+ return response
127
+ }
128
+
129
+ it('rejects with AbortError instead of reporting an empty payload', async () => {
130
+ ;(apiFetch as jest.Mock).mockResolvedValue(createAbortedBodyResponse())
131
+ const controller = new AbortController()
132
+ controller.abort()
133
+
134
+ await expect(
135
+ readApiResultOrThrow('/api/interactions', { signal: controller.signal }),
136
+ ).rejects.toMatchObject({ name: 'AbortError' })
137
+ })
138
+
139
+ it('does not mask a genuinely empty payload when the request was not aborted', async () => {
140
+ ;(apiFetch as jest.Mock).mockResolvedValue(createMockResponse('', { status: 200 }))
141
+ const controller = new AbortController()
142
+
143
+ await expect(
144
+ readApiResultOrThrow('/api/interactions', { signal: controller.signal }),
145
+ ).rejects.toThrow('Missing response payload (200)')
146
+ })
147
+
148
+ it('keeps the caller-provided fallback when one is configured', async () => {
149
+ ;(apiFetch as jest.Mock).mockResolvedValue(createAbortedBodyResponse())
150
+ const controller = new AbortController()
151
+ controller.abort()
152
+
153
+ const call = await apiCall<{ items: string[] }>(
154
+ '/api/interactions',
155
+ { signal: controller.signal },
156
+ { fallback: { items: [] } },
157
+ )
158
+ expect(call.result).toEqual({ items: [] })
159
+ })
160
+
161
+ it('propagates AbortError raised by a custom parser', async () => {
162
+ ;(apiFetch as jest.Mock).mockResolvedValue(createMockResponse('{}', { status: 200 }))
163
+ const abortError = new Error('The operation was aborted.')
164
+ abortError.name = 'AbortError'
165
+
166
+ await expect(
167
+ apiCall('/api/interactions', undefined, { parse: async () => { throw abortError } }),
168
+ ).rejects.toMatchObject({ name: 'AbortError' })
169
+ })
170
+ })
@@ -19,6 +19,27 @@ export type ApiCallResult<TReturn> = {
19
19
 
20
20
  const scopedRequestHeaders = createScopedHeaderStack()
21
21
 
22
+ function isAbortError(error: unknown): boolean {
23
+ return typeof error === 'object'
24
+ && error !== null
25
+ && (error as { name?: unknown }).name === 'AbortError'
26
+ }
27
+
28
+ function createAbortError(): Error {
29
+ if (typeof DOMException === 'function') {
30
+ return new DOMException('[internal] The operation was aborted.', 'AbortError')
31
+ }
32
+ const error = new Error('[internal] The operation was aborted.')
33
+ error.name = 'AbortError'
34
+ return error
35
+ }
36
+
37
+ function resolveAbortSignal(input: RequestInfo | URL, init?: RequestInit): AbortSignal | null {
38
+ if (init?.signal) return init.signal
39
+ if (typeof Request !== 'undefined' && input instanceof Request) return input.signal ?? null
40
+ return null
41
+ }
42
+
22
43
  function mergeHeaders(base: HeadersInit | undefined, extra: Record<string, string>): HeadersInit {
23
44
  if (!base) return extra
24
45
  if (typeof Headers !== 'undefined' && base instanceof Headers) {
@@ -63,9 +84,16 @@ export async function apiCall<TReturn = Record<string, unknown>>(
63
84
  : response
64
85
  if (parser) result = await parser(source)
65
86
  else result = await readJsonSafe<TReturn>(source, fallback)
66
- } catch {
87
+ } catch (err) {
88
+ if (isAbortError(err)) throw err
67
89
  result = fallback
68
90
  }
91
+ // `readJsonSafe` swallows the abort that cancels an in-flight body read, so a
92
+ // request aborted after its response headers arrived would otherwise look like
93
+ // a successful call that returned an empty payload.
94
+ if (result == null && resolveAbortSignal(input, init)?.aborted) {
95
+ throw createAbortError()
96
+ }
69
97
  return {
70
98
  ok: response.ok,
71
99
  status: response.status,
@@ -24,6 +24,7 @@ export function LanguageSwitcher() {
24
24
  pl: t('common.languages.polish', 'Polski'),
25
25
  es: t('common.languages.spanish', 'Español'),
26
26
  de: t('common.languages.german', 'Deutsch'),
27
+ ko: t('common.languages.korean', '한국어'),
27
28
  }
28
29
 
29
30
  async function setLocale(locale: Locale) {
@@ -0,0 +1,70 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { render } from '@testing-library/react'
5
+ import { Button, buttonVariants } from '../button'
6
+
7
+ type ButtonVariant = NonNullable<Parameters<typeof buttonVariants>[0]>['variant']
8
+
9
+ function classesOf(variant: ButtonVariant) {
10
+ return buttonVariants({ variant }).split(/\s+/).filter(Boolean)
11
+ }
12
+
13
+ describe('Button destructive variants', () => {
14
+ // The DS policy: `destructive` is the quiet trigger (red text on a calm
15
+ // surface) so a page full of delete buttons does not train users to ignore
16
+ // red; `destructive-solid` is the filled form, reserved for the single
17
+ // point-of-no-return confirmation inside a dialog. These tests pin both
18
+ // halves of that contract — swapping them back is a visual regression that
19
+ // no other test would catch.
20
+
21
+ it('destructive is quiet: destructive border and text on the page surface', () => {
22
+ const classes = classesOf('destructive')
23
+ expect(classes).toContain('border')
24
+ expect(classes).toContain('border-destructive')
25
+ expect(classes).toContain('bg-background')
26
+ expect(classes).toContain('text-destructive')
27
+ })
28
+
29
+ it('destructive does not fill with the destructive surface', () => {
30
+ const classes = classesOf('destructive')
31
+ expect(classes).not.toContain('bg-destructive')
32
+ expect(classes).not.toContain('text-white')
33
+ })
34
+
35
+ it('destructive-solid fills with the destructive surface and white glyph', () => {
36
+ const classes = classesOf('destructive-solid')
37
+ expect(classes).toContain('bg-destructive')
38
+ expect(classes).toContain('text-white')
39
+ expect(classes).not.toContain('border-destructive')
40
+ })
41
+
42
+ it('destructive-solid ships no dark background override', () => {
43
+ // `--destructive` is a semantic token that already resolves per theme; a
44
+ // `dark:bg-*` override on top of it is what broke IconButton in #3507.
45
+ const classes = classesOf('destructive-solid')
46
+ expect(classes.some((cls) => cls.startsWith('dark:bg-'))).toBe(false)
47
+ })
48
+
49
+ it('renders the quiet treatment on the element for variant="destructive"', () => {
50
+ const { getByRole } = render(<Button variant="destructive">Delete</Button>)
51
+ const className = getByRole('button').className
52
+ expect(className).toContain('text-destructive')
53
+ expect(className).toContain('border-destructive')
54
+ expect(className.split(/\s+/)).not.toContain('bg-destructive')
55
+ })
56
+
57
+ it('renders the filled treatment on the element for variant="destructive-solid"', () => {
58
+ const { getByRole } = render(<Button variant="destructive-solid">Delete permanently</Button>)
59
+ const classNames = getByRole('button').className.split(/\s+/)
60
+ expect(classNames).toContain('bg-destructive')
61
+ expect(classNames).toContain('text-white')
62
+ })
63
+
64
+ it('defaults to the primary variant when none is given', () => {
65
+ const { getByRole } = render(<Button>Save</Button>)
66
+ const classNames = getByRole('button').className.split(/\s+/)
67
+ expect(classNames).toContain('bg-primary')
68
+ expect(classNames).not.toContain('text-destructive')
69
+ })
70
+ })
@@ -9,14 +9,20 @@ const buttonVariants = cva(
9
9
  variants: {
10
10
  variant: {
11
11
  default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary-hover',
12
+ /* Destructive is quiet by design: red text on a calm surface. A wall
13
+ of solid red buttons trains users to ignore red. The filled form is
14
+ `destructive-solid`, reserved for the single point-of-no-return
15
+ confirmation inside a dialog — never for the button that opens it. */
12
16
  destructive:
13
- 'bg-destructive text-white shadow-xs hover:bg-destructive/90 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive dark:bg-destructive/10',
17
+ 'border border-destructive bg-background text-destructive shadow-xs hover:bg-destructive/10 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive',
18
+ 'destructive-solid':
19
+ 'bg-destructive text-white shadow-xs hover:bg-destructive/90 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive',
14
20
  'destructive-outline':
15
- 'border border-destructive/30 bg-background text-destructive shadow-xs hover:bg-destructive/10 dark:border-destructive/40 dark:hover:bg-destructive/15',
21
+ 'border border-destructive bg-background text-destructive shadow-xs hover:bg-destructive/10',
16
22
  'destructive-soft':
17
- 'bg-destructive/10 text-destructive hover:bg-destructive/15 dark:bg-destructive/15 dark:hover:bg-destructive/20',
23
+ 'bg-destructive/10 text-destructive hover:bg-destructive/15',
18
24
  'destructive-ghost':
19
- 'text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/15',
25
+ 'text-destructive hover:bg-destructive/10',
20
26
  outline:
21
27
  'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
22
28
  secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',