generator-pninja 2.0.1 → 2.0.2

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.
@@ -26,14 +26,17 @@ function base64ToUint8Array(base64: string): Uint8Array {
26
26
  return bytes;
27
27
  }
28
28
 
29
+ const linkClass =
30
+ 'inline-block rounded-sm hover:outline-2 hover:outline-offset-2 hover:outline-blue-600 hover:dark:outline-sky-300 focus:outline-2 focus:outline-offset-2 focus:outline-blue-600 focus:dark:outline-sky-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 focus-visible:dark:outline-sky-300';
31
+
29
32
  // ─── Image viewer ────────────────────────────────────────────────────────────
30
33
 
31
34
  const ImageViewer: React.FC<{ objectUrl: string; name: string }> = ({ objectUrl, name }) => (
32
- <a href={objectUrl} target="_blank" rel="noopener noreferrer">
35
+ <a href={objectUrl} target="_blank" rel="noopener noreferrer" className={linkClass}>
33
36
  <img
34
37
  src={objectUrl}
35
38
  alt={name}
36
- className="max-w-xs max-h-80 rounded border border-gray-200 dark:border-gray-700"
39
+ className="max-w-xs max-h-80 rounded-sm border border-gray-200 dark:border-gray-700"
37
40
  />
38
41
  </a>
39
42
  );
@@ -45,12 +48,13 @@ const VideoViewer: React.FC<{ objectUrl: string; name: string }> = ({ objectUrl,
45
48
  href={objectUrl}
46
49
  target="_blank"
47
50
  rel="noopener noreferrer"
48
- className="h-full inline-block relative"
51
+ // className="h-full inline-block relative"
52
+ className={`relative ${linkClass}`}
49
53
  >
50
54
  <video
51
55
  src={objectUrl}
52
56
  preload="metadata"
53
- className="max-w-xs max-h-80 rounded border border-gray-200 dark:border-gray-700"
57
+ className="max-w-xs max-h-80 rounded-sm border border-gray-200 dark:border-gray-700"
54
58
  />
55
59
  <div className="absolute inset-0 flex items-center justify-center bg-black/30">
56
60
  <Icon icon="play_circle" size={12} className="text-white drop-shadow" />
@@ -123,11 +127,11 @@ const PdfViewer: React.FC<{ blob: BlobValue; objectUrl: string }> = ({ blob, obj
123
127
  return (
124
128
  <>
125
129
  {thumbnailUrl && !pdfError && (
126
- <a href={objectUrl} target="_blank" rel="noopener noreferrer" className="block">
130
+ <a href={objectUrl} target="_blank" rel="noopener noreferrer" className={linkClass}>
127
131
  <img
128
132
  src={thumbnailUrl}
129
133
  alt={blob.name}
130
- className="max-w-xs rounded border border-gray-200 dark:border-gray-700"
134
+ className="max-w-xs rounded-sm border border-gray-200 dark:border-gray-700"
131
135
  />
132
136
  </a>
133
137
  )}
@@ -211,13 +215,17 @@ const BlobViewer: React.FC<BlobViewerProps> = ({ blob, className, compact = fals
211
215
  </>
212
216
  );
213
217
 
218
+ if (compact && !isInlineImage && !isVideo && !isPdf) return null;
219
+
214
220
  if (compact) {
215
221
  return (
216
222
  <div
217
223
  className={[
218
- 'w-20 h-20 flex-shrink-0 rounded overflow-hidden bg-slate-200 dark:bg-slate-600 flex items-center justify-center [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_video]:w-full [&_video]:h-full [&_video]:object-cover [&_a]:w-full [&_a]:h-full',
224
+ 'w-20 h-20 p-1 outline-2 -outline-offset-2 focus-within:outline-0 hover:outline-0 flex-shrink-0 rounded-md overflow-hidden bg-slate-200 dark:bg-slate-600 flex items-center justify-center [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_video]:w-full [&_video]:h-full [&_video]:object-cover [&_a]:w-full [&_a]:h-full',
219
225
  className,
220
- ].filter(Boolean).join(' ')}
226
+ ]
227
+ .filter(Boolean)
228
+ .join(' ')}
221
229
  >
222
230
  {preview}
223
231
  </div>
@@ -1,7 +1,7 @@
1
1
  import { useTranslation } from 'react-i18next';
2
2
  import prettyBytes from 'pretty-bytes';
3
3
  import { Icon } from '../Icon';
4
- import { useState, useEffect } from 'react';
4
+ import { useState, useEffect, useRef } from 'react';
5
5
  import { useFormContext, useController, FieldError } from 'react-hook-form';
6
6
  import { FormLabel, FormFieldInfo } from './index';
7
7
  import { useId } from 'react-aria';
@@ -114,7 +114,7 @@ const FilePreview: React.FC<{
114
114
  <button
115
115
  type="button"
116
116
  onClick={onRemove}
117
- className="flex-shrink-0 flex items-center text-sm cursor-pointer font-medium gap-1 text-slate-600 dark:text-slate-300"
117
+ className="icon-text-button flex-shrink-0 flex items-center text-sm cursor-pointer font-medium gap-1 text-slate-600 dark:text-slate-300"
118
118
  aria-label={t('actions.crud.file.removeSelection')}
119
119
  >
120
120
  <Icon icon="close" size={5} />
@@ -132,6 +132,19 @@ const DropZone: React.FC<{
132
132
  hasError: boolean;
133
133
  }> = ({ accept, isDisabled, onFileSelected, hasError }) => {
134
134
  const { t } = useTranslation();
135
+ const [isFocused, setIsFocused] = useState(false);
136
+ const dropZoneRef = useRef<HTMLDivElement>(null);
137
+
138
+ useEffect(() => {
139
+ if (!dropZoneRef.current) return;
140
+ // RAC renders an internal button with aria-label for screen reader accessibility.
141
+ // We remove it from the tab order since our FileTrigger Button handles keyboard interaction.
142
+ // Screen readers can still reach it via arrow key navigation.
143
+ const internalButton = dropZoneRef.current.querySelector<HTMLElement>(
144
+ 'button[data-react-aria-pressable][aria-label]'
145
+ );
146
+ if (internalButton) internalButton.tabIndex = -1;
147
+ }, []);
135
148
 
136
149
  const handleDrop = async (e: import('react-aria-components').DropEvent) => {
137
150
  const droppedItem = e.items.find((item) => item.kind === 'file');
@@ -142,13 +155,17 @@ const DropZone: React.FC<{
142
155
 
143
156
  return (
144
157
  <AriaDropZone
158
+ ref={dropZoneRef}
145
159
  onDrop={handleDrop}
146
160
  isDisabled={isDisabled}
161
+ tabIndex={-1}
147
162
  className={({ isDropTarget }) =>
148
163
  [
149
- 'flex flex-col items-center justify-center gap-2 p-6 rounded-md border-2 border-dashed transition-colors outline-none',
150
- isDropTarget
151
- ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
164
+ 'rounded-md border-2 border-dashed transition-colors',
165
+ isDropTarget || isFocused
166
+ ? hasError
167
+ ? 'border-red-400 bg-red-50 dark:bg-red-900/20'
168
+ : 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
152
169
  : hasError
153
170
  ? 'border-red-400 bg-red-50/30 dark:bg-red-900/10'
154
171
  : 'border-slate-300 dark:border-slate-600',
@@ -167,14 +184,20 @@ const DropZone: React.FC<{
167
184
  >
168
185
  <Button
169
186
  isDisabled={isDisabled}
170
- className="flex flex-col items-center gap-2 cursor-pointer w-full focus:outline-none"
187
+ isInvalid={hasError}
188
+ className="drop-zone-button flex flex-col items-center justify-center gap-2 p-6"
189
+ onFocusChange={setIsFocused}
171
190
  >
172
191
  <Icon
173
192
  icon="cloud_upload"
174
193
  size={8}
175
- className={isDropTarget ? 'text-blue-500' : 'text-slate-400'}
194
+ className={isDropTarget || isFocused
195
+ ? hasError ? 'text-red-400' : 'text-blue-500'
196
+ : 'text-slate-400'}
176
197
  />
177
- <span className="text-sm text-slate-600 dark:text-slate-400 text-center">
198
+ <span className={`text-sm text-center ${isDropTarget || isFocused
199
+ ? hasError ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'
200
+ : 'text-slate-600 dark:text-slate-400'}`}>
178
201
  {isDropTarget
179
202
  ? t('actions.crud.file.dropHere')
180
203
  : t('actions.crud.file.dragOrClick')}
@@ -207,18 +230,35 @@ const FileField: React.FC<FileFieldProps> = ({
207
230
  const { required, minbytes, maxbytes } = validations || {};
208
231
  const {
209
232
  control,
210
- register,
211
233
  setValue,
212
234
  trigger,
213
- formState: { errors },
214
235
  } = useFormContext();
215
236
 
216
237
  const {
238
+ field: { ref: fieldRef },
217
239
  fieldState: { error },
218
240
  } = useController({
219
241
  name: fieldName,
220
242
  control,
221
- rules: {},
243
+ rules: {
244
+ validate: (value: unknown | undefined) => {
245
+ const fileData = value as BlobValue | null;
246
+
247
+ if (fileData?.type && !fileData.type.startsWith(accept?.replace(/\/\*$/, '') ?? '')) {
248
+ return `errors.validations.file.invalidType:${accept}:${fileData.type}`;
249
+ }
250
+ if (typeof fileData?.size === 'number' && minbytes && fileData?.size < minbytes) {
251
+ return `errors.validations.file.minSize:${prettyBytes(minbytes)}:${prettyBytes(fileData?.size)}`;
252
+ }
253
+ if (typeof fileData?.size === 'number' && maxbytes && fileData?.size > maxbytes) {
254
+ return `errors.validations.file.maxSize:${prettyBytes(maxbytes)}:${prettyBytes(fileData?.size)}`;
255
+ }
256
+ if (required && (isMarkedForDeletion || (!fileData && !existingBlob?.name))) {
257
+ return 'errors.validations.file.required';
258
+ }
259
+ return true;
260
+ },
261
+ },
222
262
  });
223
263
 
224
264
  const markForDeletion = (mark: boolean) => {
@@ -226,28 +266,6 @@ const FileField: React.FC<FileFieldProps> = ({
226
266
  setValue(fieldName, mark ? null : existingBlob);
227
267
  setTimeout(() => trigger(fieldName), 0);
228
268
  };
229
-
230
- // Register the field in RHF for validation — not bound to a native input
231
- register(fieldName, {
232
- validate: (value: unknown | undefined) => {
233
- const fileData = value as BlobValue | null;
234
-
235
- if (fileData?.type && !fileData.type.startsWith(accept?.replace(/\/\*$/, '') ?? '')) {
236
- return `errors.validations.file.invalidType:${accept}:${fileData.type}`;
237
- }
238
- if (typeof fileData?.size === 'number' && minbytes && fileData?.size < minbytes) {
239
- return `errors.validations.file.minSize:${prettyBytes(minbytes)}:${prettyBytes(fileData?.size)}`;
240
- }
241
- if (typeof fileData?.size === 'number' && maxbytes && fileData?.size > maxbytes) {
242
- return `errors.validations.file.maxSize:${prettyBytes(maxbytes)}:${prettyBytes(fileData?.size)}`;
243
- }
244
- if (required && (isMarkedForDeletion || (!fileData && !existingBlob?.name))) {
245
- return 'errors.validations.file.required';
246
- }
247
- return true;
248
- },
249
- });
250
-
251
269
  const processFile = async (file: File) => {
252
270
  setSelectedFile(file);
253
271
  try {
@@ -281,6 +299,7 @@ const FileField: React.FC<FileFieldProps> = ({
281
299
  setTimeout(() => trigger(fieldName), 0);
282
300
  };
283
301
 
302
+ const containerRef = useRef<HTMLDivElement>(null);
284
303
  const fieldId = useId();
285
304
  const labelId = useId();
286
305
  const errorId = useId();
@@ -289,11 +308,24 @@ const FileField: React.FC<FileFieldProps> = ({
289
308
  const showDropZone = shouldShowInput && !isMarkedForDeletion;
290
309
 
291
310
  return (
292
- <div className={className}>
311
+ <div className={className} ref={containerRef}>
293
312
  <FormLabel id={labelId} validations={validations}>
294
313
  {label}
295
314
  </FormLabel>
296
315
  <div className="space-y-2">
316
+ {/* Focus anchor for RHF scroll-to-error */}
317
+ <span
318
+ ref={fieldRef}
319
+ tabIndex={-1}
320
+ className="sr-only"
321
+ aria-hidden="true"
322
+ onFocus={() => {
323
+ const firstButton = containerRef.current?.querySelector<HTMLElement>(
324
+ 'button:not([tabindex="-1"]), [tabindex="0"]'
325
+ );
326
+ firstButton?.focus();
327
+ }}
328
+ />
297
329
 
298
330
  {/* File esistente */}
299
331
  {hasFile && !shouldShowInput && !isMarkedForDeletion && (
@@ -301,7 +333,7 @@ const FileField: React.FC<FileFieldProps> = ({
301
333
  <div className="flex items-center justify-between">
302
334
  <a
303
335
  href={`${location.pathname.replace('entities', 'api').replace('edit/', '')}/${fieldName}/${existingBlob?.name}`}
304
- className="hover:underline flex items-center gap-2 text-sm"
336
+ className="icon-text-button hover:underline flex items-center gap-2 text-sm"
305
337
  target="_blank"
306
338
  rel="noopener noreferrer"
307
339
  aria-label={`${t('actions.crud.file.viewOrDownload')} ${existingBlob?.name} (${t('common.opensInNewWindow')})`}
@@ -316,7 +348,7 @@ const FileField: React.FC<FileFieldProps> = ({
316
348
  <button
317
349
  type="button"
318
350
  onClick={() => setShouldShowInput(true)}
319
- className="flex items-center text-sm cursor-pointer font-medium gap-1"
351
+ className="icon-text-button flex items-center text-sm cursor-pointer font-medium gap-1"
320
352
  >
321
353
  <Icon icon="swap_horiz" size={5} />
322
354
  {t('actions.crud.edit')}
@@ -324,7 +356,7 @@ const FileField: React.FC<FileFieldProps> = ({
324
356
  <button
325
357
  type="button"
326
358
  onClick={() => markForDeletion(true)}
327
- className="flex items-center text-sm cursor-pointer font-medium gap-1"
359
+ className="icon-text-button flex items-center text-sm cursor-pointer font-medium gap-1"
328
360
  >
329
361
  <Icon icon="delete" size={5} />
330
362
  {t('actions.crud.delete')}
@@ -350,7 +382,7 @@ const FileField: React.FC<FileFieldProps> = ({
350
382
  <button
351
383
  type="button"
352
384
  onClick={() => markForDeletion(false)}
353
- className="flex items-center text-sm cursor-pointer font-medium gap-1"
385
+ className="icon-text-button flex items-center text-sm cursor-pointer font-medium gap-1"
354
386
  >
355
387
  <Icon icon="undo" size={5} />
356
388
  {t('common.undo')}
@@ -372,7 +404,7 @@ const FileField: React.FC<FileFieldProps> = ({
372
404
  <button
373
405
  type="button"
374
406
  onClick={undoEdit}
375
- className="flex items-center text-sm cursor-pointer font-medium gap-1"
407
+ className="icon-text-button flex items-center text-sm cursor-pointer font-medium gap-1"
376
408
  >
377
409
  <Icon icon="undo" size={5} />
378
410
  {t('common.cancel')}
@@ -75,6 +75,14 @@
75
75
  focus:inset-ring-0 focus:outline-2 focus:outline-offset-2 focus:outline-red-600 dark:focus:outline-red-500
76
76
  inset-ring-2 inset-ring-red-600 dark:inset-ring-red-500;
77
77
  }
78
+ .drop-zone-button {
79
+ @apply outline-0 rounded-md w-full cursor-pointer transition-colors;
80
+ }
81
+ .icon-text-button {
82
+ @apply rounded-full cursor-pointer
83
+ focus:outline-2 focus:outline-offset-2 focus:outline-blue-600 focus:dark:outline-sky-300
84
+ focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 focus-visible:dark:outline-sky-300;
85
+ }
78
86
  .kdb-shortcut {
79
87
  @apply font-sans text-xs h-5 min-w-5 items-center justify-center rounded border px-1 hidden xl:inline-flex;
80
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "generator-pninja",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Generator for PHP/Laravel + SQL + Vite + React applications",
5
5
  "type": "module",
6
6
  "files": [