@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/dist/components/Autocomplete.d.ts.map +1 -1
- package/dist/components/Combobox.d.ts.map +1 -1
- package/dist/components/DatePicker.d.ts.map +1 -1
- package/dist/components/DateRangePicker.d.ts.map +1 -1
- package/dist/components/Input.d.ts.map +1 -1
- package/dist/components/Modal.d.ts.map +1 -1
- package/dist/components/Select.d.ts +2 -0
- package/dist/components/Select.d.ts.map +1 -1
- package/dist/components/TimePicker.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.esm.js +74 -21
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +73 -20
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Autocomplete.tsx +1 -0
- package/src/components/Combobox.tsx +1 -0
- package/src/components/DatePicker.tsx +1 -0
- package/src/components/DateRangePicker.tsx +1 -0
- package/src/components/Input.tsx +13 -3
- package/src/components/Modal.tsx +64 -8
- package/src/components/Select.tsx +7 -0
- package/src/components/TimePicker.tsx +1 -0
package/package.json
CHANGED
|
@@ -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
|
|
package/src/components/Input.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
)}
|
package/src/components/Modal.tsx
CHANGED
|
@@ -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
|
|
132
|
-
|
|
133
|
-
const
|
|
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
|
-
|
|
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',
|
|
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
|
|