@papernote/ui 1.10.16 → 1.10.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papernote/ui",
3
- "version": "1.10.16",
3
+ "version": "1.10.17",
4
4
  "type": "module",
5
5
  "description": "A modern React component library with a paper notebook aesthetic - minimal, professional, and expressive",
6
6
  "main": "dist/index.js",
@@ -320,6 +320,7 @@ const Autocomplete = forwardRef<AutocompleteHandle, AutocompleteProps>(({
320
320
  aria-activedescendant={highlightedIndex >= 0 ? `autocomplete-option-${highlightedIndex}` : undefined}
321
321
  aria-invalid={error ? 'true' : undefined}
322
322
  aria-describedby={error ? errorId : undefined}
323
+ aria-required={required}
323
324
  aria-busy={loading}
324
325
  />
325
326
 
@@ -312,6 +312,7 @@ const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(({
312
312
  aria-activedescendant={isOpen && filteredOptions.length > 0 ? `option-${highlightedIndex}` : undefined}
313
313
  aria-invalid={validationState === 'error' ? 'true' : undefined}
314
314
  aria-describedby={validationMessage ? descriptionId : undefined}
315
+ aria-required={required}
315
316
  role="combobox"
316
317
  />
317
318
 
@@ -349,6 +349,7 @@ const DatePicker = forwardRef<DatePickerHandle, DatePickerProps>(({
349
349
  aria-controls={dialogId}
350
350
  aria-invalid={validationState === 'error' ? 'true' : undefined}
351
351
  aria-describedby={validationMessage ? descriptionId : undefined}
352
+ aria-required={required}
352
353
  role="combobox"
353
354
  />
354
355
 
@@ -342,6 +342,7 @@ const DateRangePicker = forwardRef<DateRangePickerHandle, DateRangePickerProps>(
342
342
  aria-controls={dialogId}
343
343
  aria-invalid={validationState === 'error' ? 'true' : undefined}
344
344
  aria-describedby={validationMessage ? descriptionId : (isOpen ? hintId : undefined)}
345
+ aria-required={required}
345
346
  role="combobox"
346
347
  />
347
348
 
@@ -1,4 +1,4 @@
1
- import React, { forwardRef, useState } from 'react';
1
+ import React, { forwardRef, useState, useId } from 'react';
2
2
  import { AlertCircle, CheckCircle, AlertTriangle, Eye, EyeOff, X, Loader2 } from 'lucide-react';
3
3
 
4
4
  /**
@@ -166,7 +166,9 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
166
166
  },
167
167
  ref
168
168
  ) => {
169
- const inputId = id || `input-${Math.random().toString(36).substring(2, 9)}`;
169
+ const generatedId = useId();
170
+ const inputId = id || generatedId;
171
+ const helperId = `${inputId}-helper`;
170
172
  const [showPassword, setShowPassword] = useState(false);
171
173
 
172
174
  // Handle clear button click
@@ -299,6 +301,9 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
299
301
  maxLength={maxLength}
300
302
  inputMode={effectiveInputMode}
301
303
  enterKeyHint={enterKeyHint}
304
+ aria-invalid={validationState === 'error'}
305
+ aria-describedby={helperText || validationMessage ? helperId : undefined}
306
+ aria-required={props.required}
302
307
  className={`
303
308
  input
304
309
  ${sizeClasses[size]}
@@ -382,7 +387,12 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
382
387
  {/* Helper Text, Validation Message, or Character Counter */}
383
388
  <div className="flex justify-between items-center mt-2">
384
389
  {(helperText || validationMessage) && (
385
- <p className={`text-xs ${validationMessage ? getValidationMessageColor() : 'text-ink-600'}`}>
390
+ <p
391
+ id={helperId}
392
+ className={`text-xs ${validationMessage ? getValidationMessageColor() : 'text-ink-600'}`}
393
+ role={validationState === 'error' ? 'alert' : undefined}
394
+ aria-live={validationState === 'error' ? 'assertive' : undefined}
395
+ >
386
396
  {validationMessage || helperText}
387
397
  </p>
388
398
  )}
@@ -1,9 +1,12 @@
1
- import React, { useEffect, useRef, useId } from 'react';
1
+ import React, { useEffect, useRef, useId, useCallback } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { X } from 'lucide-react';
4
4
  import { useIsMobile } from '../hooks/useResponsive';
5
5
  import BottomSheet from './BottomSheet';
6
6
 
7
+ // Selector for all focusable elements
8
+ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
9
+
7
10
  export interface ModalProps {
8
11
  isOpen: boolean;
9
12
  onClose: () => void;
@@ -118,34 +121,86 @@ export default function Modal({
118
121
  }: ModalProps) {
119
122
  const modalRef = useRef<HTMLDivElement>(null);
120
123
  const mouseDownOnBackdrop = useRef(false);
124
+ const previousActiveElement = useRef<HTMLElement | null>(null);
121
125
  const titleId = useId();
122
126
  const isMobile = useIsMobile();
123
127
 
128
+ // Get all focusable elements within the modal
129
+ const getFocusableElements = useCallback(() => {
130
+ if (!modalRef.current) return [];
131
+ return Array.from(modalRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
132
+ .filter(el => el.offsetParent !== null); // Filter out hidden elements
133
+ }, []);
134
+
124
135
  // Determine if we should use BottomSheet
125
136
  const useBottomSheet =
126
137
  mobileMode === 'sheet' ||
127
138
  (mobileMode === 'auto' && isMobile);
128
139
 
129
- // Handle escape key (only for modal mode, BottomSheet handles its own)
140
+ // Handle escape key and focus trap (only for modal mode, BottomSheet handles its own)
130
141
  useEffect(() => {
131
- if (useBottomSheet) return; // BottomSheet handles escape
132
-
133
- const handleEscape = (e: KeyboardEvent) => {
142
+ if (useBottomSheet) return; // BottomSheet handles its own focus
143
+
144
+ const handleKeyDown = (e: KeyboardEvent) => {
134
145
  if (e.key === 'Escape' && isOpen) {
135
146
  onClose();
147
+ return;
148
+ }
149
+
150
+ // Focus trap: keep focus within modal
151
+ if (e.key === 'Tab' && isOpen) {
152
+ const focusableElements = getFocusableElements();
153
+ if (focusableElements.length === 0) return;
154
+
155
+ const firstElement = focusableElements[0];
156
+ const lastElement = focusableElements[focusableElements.length - 1];
157
+
158
+ if (e.shiftKey) {
159
+ // Shift+Tab: if on first element, wrap to last
160
+ if (document.activeElement === firstElement) {
161
+ e.preventDefault();
162
+ lastElement.focus();
163
+ }
164
+ } else {
165
+ // Tab: if on last element, wrap to first
166
+ if (document.activeElement === lastElement) {
167
+ e.preventDefault();
168
+ firstElement.focus();
169
+ }
170
+ }
136
171
  }
137
172
  };
138
173
 
139
174
  if (isOpen) {
140
- document.addEventListener('keydown', handleEscape);
175
+ // Store the currently focused element to restore later
176
+ previousActiveElement.current = document.activeElement as HTMLElement;
177
+
178
+ document.addEventListener('keydown', handleKeyDown);
141
179
  document.body.style.overflow = 'hidden';
180
+
181
+ // Set initial focus to first focusable element
182
+ // Use requestAnimationFrame to ensure the modal is rendered
183
+ requestAnimationFrame(() => {
184
+ const focusableElements = getFocusableElements();
185
+ if (focusableElements.length > 0) {
186
+ focusableElements[0].focus();
187
+ } else if (modalRef.current) {
188
+ // If no focusable elements, focus the modal container
189
+ modalRef.current.focus();
190
+ }
191
+ });
142
192
  }
143
193
 
144
194
  return () => {
145
- document.removeEventListener('keydown', handleEscape);
195
+ document.removeEventListener('keydown', handleKeyDown);
146
196
  document.body.style.overflow = 'unset';
197
+
198
+ // Restore focus to the previously focused element
199
+ if (previousActiveElement.current && typeof previousActiveElement.current.focus === 'function') {
200
+ previousActiveElement.current.focus();
201
+ }
147
202
  };
148
- }, [isOpen, onClose, useBottomSheet]);
203
+ }, [isOpen, onClose, useBottomSheet, getFocusableElements]);
149
204
 
150
205
  // Track if mousedown originated on the backdrop
151
206
  const handleBackdropMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
@@ -214,6 +269,7 @@ export default function Modal({
214
269
  role="dialog"
215
270
  aria-modal="true"
216
271
  aria-labelledby={titleId}
272
+ tabIndex={-1}
217
273
  >
218
274
  {/* Header */}
219
275
  <div className="flex items-center justify-between px-6 py-4 border-b border-paper-200">
@@ -85,6 +85,8 @@ export interface SelectProps {
85
85
  mobileMode?: 'auto' | 'dropdown' | 'native';
86
86
  /** Render dropdown via portal (default: true). Set to false when overflow clipping is not an issue */
87
87
  usePortal?: boolean;
88
+ /** Whether this field is required */
89
+ required?: boolean;
88
90
  }
89
91
 
90
92
  // Size classes for trigger button
@@ -194,6 +196,7 @@ const Select = forwardRef<SelectHandle, SelectProps>(
194
196
  size = 'md',
195
197
  mobileMode = 'auto',
196
198
  usePortal = true,
199
+ required = false,
197
200
  } = props;
198
201
  const [isOpen, setIsOpen] = useState(false);
199
202
  const [searchQuery, setSearchQuery] = useState('');
@@ -566,6 +569,7 @@ const Select = forwardRef<SelectHandle, SelectProps>(
566
569
  {label && (
567
570
  <label id={labelId} className="label">
568
571
  {label}
572
+ {required && <span className="text-error-500 ml-1">*</span>}
569
573
  </label>
570
574
  )}
571
575
 
@@ -584,6 +588,7 @@ const Select = forwardRef<SelectHandle, SelectProps>(
584
588
  aria-labelledby={label ? labelId : undefined}
585
589
  aria-invalid={error ? 'true' : undefined}
586
590
  aria-describedby={error ? errorId : (helperText ? helperTextId : undefined)}
591
+ aria-required={required}
587
592
  >
588
593
  <option value="" disabled>{placeholder}</option>
589
594
  {options.map((opt) => (
@@ -624,6 +629,7 @@ const Select = forwardRef<SelectHandle, SelectProps>(
624
629
  {label && (
625
630
  <label id={labelId} className="label">
626
631
  {label}
632
+ {required && <span className="text-error-500 ml-1">*</span>}
627
633
  </label>
628
634
  )}
629
635
 
@@ -651,6 +657,7 @@ const Select = forwardRef<SelectHandle, SelectProps>(
651
657
  aria-invalid={error ? 'true' : undefined}
652
658
  aria-describedby={error ? errorId : (helperText ? helperTextId : undefined)}
653
659
  aria-disabled={disabled}
660
+ aria-required={required}
654
661
  >
655
662
  <span className={`flex items-center gap-2 ${selectedOption ? 'text-ink-800' : 'text-ink-400'}`}>
656
663
  {loading && <Loader2 className="h-4 w-4 animate-spin text-ink-500" />}
@@ -292,6 +292,7 @@ const TimePicker = forwardRef<TimePickerHandle, TimePickerProps>(({
292
292
  aria-controls={dropdownId}
293
293
  aria-invalid={validationState === 'error' ? 'true' : undefined}
294
294
  aria-describedby={validationMessage ? descriptionId : undefined}
295
+ aria-required={required}
295
296
  role="combobox"
296
297
  />
297
298