@unisane/ui 0.1.1 → 0.1.2-next.20261102.1
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/combobox.d.ts +8 -0
- package/dist/components/combobox.d.ts.map +1 -1
- package/dist/components/combobox.js +60 -13
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/dialog.js +2 -2
- package/dist/components/dialog.js.map +1 -1
- package/dist/components/dropdown-menu.d.ts.map +1 -1
- package/dist/components/dropdown-menu.js +7 -3
- package/dist/components/dropdown-menu.js.map +1 -1
- package/dist/components/month-picker.d.ts +2 -1
- package/dist/components/month-picker.d.ts.map +1 -1
- package/dist/components/month-picker.js +4 -4
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/popover.d.ts.map +1 -1
- package/dist/components/popover.js +4 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/select-field.d.ts +2 -1
- package/dist/components/select-field.d.ts.map +1 -1
- package/dist/components/select-field.js +9 -7
- package/dist/components/select-field.js.map +1 -1
- package/dist/components/select.d.ts.map +1 -1
- package/dist/components/select.js +10 -8
- package/dist/components/select.js.map +1 -1
- package/dist/components/sheet.d.ts.map +1 -1
- package/dist/components/sheet.js +6 -4
- package/dist/components/sheet.js.map +1 -1
- package/dist/components/text-field.d.ts +2 -1
- package/dist/components/text-field.d.ts.map +1 -1
- package/dist/components/text-field.js +39 -10
- package/dist/components/text-field.js.map +1 -1
- package/dist/components/token-field.d.ts +3 -1
- package/dist/components/token-field.d.ts.map +1 -1
- package/dist/components/token-field.js +7 -7
- package/dist/components/token-field.js.map +1 -1
- package/dist/index.css +5 -0
- package/dist/lib/field-shell.d.ts +3 -1
- package/dist/lib/field-shell.d.ts.map +1 -1
- package/dist/lib/field-shell.js +7 -2
- package/dist/lib/field-shell.js.map +1 -1
- package/dist/lib/portal-layer.d.ts +3 -0
- package/dist/lib/portal-layer.d.ts.map +1 -1
- package/dist/lib/portal-layer.js +44 -1
- package/dist/lib/portal-layer.js.map +1 -1
- package/dist/lib/use-overlay-behavior.d.ts.map +1 -1
- package/dist/lib/use-overlay-behavior.js +7 -2
- package/dist/lib/use-overlay-behavior.js.map +1 -1
- package/package.json +3 -3
- package/registry/components/combobox.tsx +118 -19
- package/registry/components/dialog.tsx +8 -8
- package/registry/components/dropdown-menu.tsx +11 -3
- package/registry/components/month-picker.tsx +6 -3
- package/registry/components/popover.tsx +9 -2
- package/registry/components/select-field.tsx +31 -6
- package/registry/components/select.tsx +22 -11
- package/registry/components/sheet.tsx +11 -9
- package/registry/components/text-field.tsx +75 -24
- package/registry/components/token-field.tsx +40 -17
- package/registry/lib/field-shell.ts +19 -3
- package/registry/lib/portal-layer.ts +47 -1
- package/registry/lib/use-overlay-behavior.ts +7 -2
- package/registry/registry.json +1 -0
|
@@ -8,8 +8,13 @@ import { Icon } from '@/components/ui/icon';
|
|
|
8
8
|
import { getFieldSizeStyles, type FieldSize } from '@/lib/field-size';
|
|
9
9
|
import { useControllableState } from '@/lib/use-controllable-state';
|
|
10
10
|
import { fieldContainerVariants, getFieldAffixClasses } from '@/lib/field-shell';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
getPortalContainer,
|
|
13
|
+
getPortalLayerStyle,
|
|
14
|
+
observePortalAnchorMotion,
|
|
15
|
+
} from '@/lib/portal-layer';
|
|
12
16
|
import { Ripple } from '@/components/ui/ripple';
|
|
17
|
+
import { Button } from '@/components/ui/button';
|
|
13
18
|
import { useOverlayBehavior } from '@/lib/use-overlay-behavior';
|
|
14
19
|
|
|
15
20
|
export type ComboboxOption = {
|
|
@@ -28,6 +33,14 @@ export type ComboboxProps = {
|
|
|
28
33
|
onSearchChange?: (query: string) => void;
|
|
29
34
|
highlightSelected?: boolean;
|
|
30
35
|
options: ComboboxOption[];
|
|
36
|
+
/** Disable local label filtering for server-filtered results. */
|
|
37
|
+
filterOptions?: boolean;
|
|
38
|
+
loading?: boolean;
|
|
39
|
+
error?: string;
|
|
40
|
+
onRetry?: () => void;
|
|
41
|
+
hasMore?: boolean;
|
|
42
|
+
onLoadMore?: () => void;
|
|
43
|
+
emptyText?: string;
|
|
31
44
|
placeholder?: string;
|
|
32
45
|
label?: string;
|
|
33
46
|
size?: FieldSize;
|
|
@@ -49,6 +62,13 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
49
62
|
onSearchChange,
|
|
50
63
|
highlightSelected = true,
|
|
51
64
|
options,
|
|
65
|
+
filterOptions = true,
|
|
66
|
+
loading = false,
|
|
67
|
+
error,
|
|
68
|
+
onRetry,
|
|
69
|
+
hasMore = false,
|
|
70
|
+
onLoadMore,
|
|
71
|
+
emptyText = 'No results found',
|
|
52
72
|
placeholder = 'Search or select...',
|
|
53
73
|
label,
|
|
54
74
|
size = 'md',
|
|
@@ -65,6 +85,17 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
65
85
|
const comboboxRef = useRef<HTMLDivElement>(null);
|
|
66
86
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
67
87
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
88
|
+
const footerActionRef = useRef<HTMLButtonElement | null>(null);
|
|
89
|
+
const setFooterActionRef = useCallback((node: HTMLButtonElement | null) => {
|
|
90
|
+
// The last result page removes the paging action. Keep keyboard users
|
|
91
|
+
// in the picker rather than leaving focus on document.body.
|
|
92
|
+
if (!node && footerActionRef.current === document.activeElement) {
|
|
93
|
+
(
|
|
94
|
+
inputRef.current ?? comboboxRef.current?.querySelector<HTMLElement>('[role="combobox"]')
|
|
95
|
+
)?.focus();
|
|
96
|
+
}
|
|
97
|
+
footerActionRef.current = node;
|
|
98
|
+
}, []);
|
|
68
99
|
const blurTimeoutRef = useRef<number | null>(null);
|
|
69
100
|
const fieldSize = getFieldSizeStyles(size);
|
|
70
101
|
const listboxId = useId();
|
|
@@ -123,9 +154,9 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
123
154
|
|
|
124
155
|
const selectedOption = options.find((option) => option.value === selectedValue);
|
|
125
156
|
|
|
126
|
-
const filteredOptions =
|
|
127
|
-
option.label.toLowerCase().includes(searchValue.toLowerCase())
|
|
128
|
-
|
|
157
|
+
const filteredOptions = filterOptions
|
|
158
|
+
? options.filter((option) => option.label.toLowerCase().includes(searchValue.toLowerCase()))
|
|
159
|
+
: options;
|
|
129
160
|
|
|
130
161
|
const updateDropdownPosition = useCallback(() => {
|
|
131
162
|
if (!comboboxRef.current || typeof window === 'undefined') return;
|
|
@@ -180,11 +211,13 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
180
211
|
setIsPositioned(false);
|
|
181
212
|
updatePosition();
|
|
182
213
|
const raf = window.requestAnimationFrame(updatePosition);
|
|
214
|
+
const stopMotion = observePortalAnchorMotion(comboboxRef.current, updatePosition);
|
|
183
215
|
window.addEventListener('scroll', updatePosition, true);
|
|
184
216
|
window.addEventListener('resize', updatePosition);
|
|
185
217
|
|
|
186
218
|
return () => {
|
|
187
219
|
window.cancelAnimationFrame(raf);
|
|
220
|
+
stopMotion();
|
|
188
221
|
window.removeEventListener('scroll', updatePosition, true);
|
|
189
222
|
window.removeEventListener('resize', updatePosition);
|
|
190
223
|
};
|
|
@@ -228,7 +261,10 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
228
261
|
clearTimeout(blurTimeoutRef.current);
|
|
229
262
|
}
|
|
230
263
|
blurTimeoutRef.current = window.setTimeout(() => {
|
|
231
|
-
if (
|
|
264
|
+
if (
|
|
265
|
+
!comboboxRef.current?.contains(document.activeElement) &&
|
|
266
|
+
!dropdownRef.current?.contains(document.activeElement)
|
|
267
|
+
) {
|
|
232
268
|
setOpenState(false);
|
|
233
269
|
setIsSearching(false);
|
|
234
270
|
setSearchValue('');
|
|
@@ -242,6 +278,17 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
242
278
|
if (disabled) return;
|
|
243
279
|
|
|
244
280
|
switch (event.key) {
|
|
281
|
+
case 'Tab': {
|
|
282
|
+
const action =
|
|
283
|
+
dropdownRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)');
|
|
284
|
+
if (isOpen && !event.shiftKey && action) {
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
action.focus();
|
|
287
|
+
} else {
|
|
288
|
+
setOpenState(false);
|
|
289
|
+
}
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
245
292
|
case 'ArrowDown':
|
|
246
293
|
event.preventDefault();
|
|
247
294
|
if (!isOpen) {
|
|
@@ -332,8 +379,8 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
332
379
|
|
|
333
380
|
<div
|
|
334
381
|
className={cn(
|
|
335
|
-
'flex h-full min-w-0 items-center',
|
|
336
|
-
fieldSize.
|
|
382
|
+
'flex h-full min-w-0 flex-1 items-center',
|
|
383
|
+
fieldSize.leadingPadding,
|
|
337
384
|
size === 'lg' ? 'gap-3' : 'gap-2',
|
|
338
385
|
)}
|
|
339
386
|
>
|
|
@@ -346,7 +393,13 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
346
393
|
value={isSearching ? searchValue : selectedOption ? selectedOption.label : ''}
|
|
347
394
|
onChange={(event) => handleInputChange(event.target.value)}
|
|
348
395
|
onKeyDown={handleKeyDown}
|
|
349
|
-
|
|
396
|
+
onClick={() => {
|
|
397
|
+
// Selection leaves focus on the input. A second click must
|
|
398
|
+
// reopen its choices without requiring a blur/focus cycle.
|
|
399
|
+
if (!disabled && !isOpen) setOpenState(true);
|
|
400
|
+
}}
|
|
401
|
+
onFocus={(event) => {
|
|
402
|
+
if (dropdownRef.current?.contains(event.relatedTarget as Node | null)) return;
|
|
350
403
|
if (!disabled) {
|
|
351
404
|
setOpenState(true);
|
|
352
405
|
setIsSearching(true);
|
|
@@ -405,7 +458,7 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
405
458
|
>
|
|
406
459
|
<Icon
|
|
407
460
|
symbol="arrow_drop_down"
|
|
408
|
-
size=
|
|
461
|
+
size={size === 'lg' ? 'md' : 'sm'}
|
|
409
462
|
className={cn(
|
|
410
463
|
'duration-short ease-standard transition-transform',
|
|
411
464
|
isOpen && 'rotate-180',
|
|
@@ -423,7 +476,7 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
423
476
|
ref={dropdownRef}
|
|
424
477
|
className={cn(
|
|
425
478
|
'bg-surface border-outline-soft shadow-2 z-[var(--z-popover,2000)] overflow-y-auto rounded-sm border',
|
|
426
|
-
portal ? 'fixed' : 'absolute top-[calc(100%+var(--unit))]
|
|
479
|
+
portal ? 'fixed' : 'absolute left-0 right-0 top-[calc(100%+var(--unit))]',
|
|
427
480
|
)}
|
|
428
481
|
style={
|
|
429
482
|
portal
|
|
@@ -437,11 +490,15 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
437
490
|
}
|
|
438
491
|
: undefined
|
|
439
492
|
}
|
|
440
|
-
|
|
441
|
-
id={listboxId}
|
|
442
|
-
aria-label={label || 'Options'}
|
|
493
|
+
onBlur={handleBlur}
|
|
443
494
|
>
|
|
444
|
-
<div
|
|
495
|
+
<div
|
|
496
|
+
className="py-1"
|
|
497
|
+
role="listbox"
|
|
498
|
+
id={listboxId}
|
|
499
|
+
aria-label={label || 'Options'}
|
|
500
|
+
aria-busy={loading}
|
|
501
|
+
>
|
|
445
502
|
{filteredOptions.length > 0 ? (
|
|
446
503
|
filteredOptions.map((option, index) => (
|
|
447
504
|
<div
|
|
@@ -458,7 +515,7 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
458
515
|
activeIndex === index &&
|
|
459
516
|
(!highlightSelected || selectedValue !== option.value) &&
|
|
460
517
|
'bg-state-hover',
|
|
461
|
-
option.disabled && 'cursor-not-allowed
|
|
518
|
+
option.disabled && 'opacity-38 cursor-not-allowed',
|
|
462
519
|
)}
|
|
463
520
|
onClick={() => handleSelect(option)}
|
|
464
521
|
role="option"
|
|
@@ -474,7 +531,7 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
474
531
|
)}
|
|
475
532
|
</div>
|
|
476
533
|
))
|
|
477
|
-
) : (
|
|
534
|
+
) : !loading && !error ? (
|
|
478
535
|
<div
|
|
479
536
|
className={cn(
|
|
480
537
|
'text-on-surface-variant font-medium',
|
|
@@ -483,15 +540,57 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
483
540
|
fieldSize.optionPaddingX,
|
|
484
541
|
)}
|
|
485
542
|
>
|
|
486
|
-
|
|
543
|
+
{emptyText}
|
|
487
544
|
</div>
|
|
488
|
-
)}
|
|
545
|
+
) : null}
|
|
489
546
|
</div>
|
|
547
|
+
{(loading || error || (hasMore && onLoadMore)) && (
|
|
548
|
+
<div
|
|
549
|
+
className={cn('border-outline-soft border-t py-2', fieldSize.optionPaddingX)}
|
|
550
|
+
>
|
|
551
|
+
{loading && (
|
|
552
|
+
<p role="status" className="text-on-surface-variant text-label-medium">
|
|
553
|
+
Loading results…
|
|
554
|
+
</p>
|
|
555
|
+
)}
|
|
556
|
+
{error && (
|
|
557
|
+
<p role="alert" className="text-error text-label-medium">
|
|
558
|
+
{error}
|
|
559
|
+
</p>
|
|
560
|
+
)}
|
|
561
|
+
{((error && onRetry) || (!error && hasMore && onLoadMore)) && (
|
|
562
|
+
<Button
|
|
563
|
+
ref={setFooterActionRef}
|
|
564
|
+
type="button"
|
|
565
|
+
variant="text"
|
|
566
|
+
size={size}
|
|
567
|
+
aria-disabled={loading || undefined}
|
|
568
|
+
onKeyDown={(event) => {
|
|
569
|
+
if (event.key === 'Tab') {
|
|
570
|
+
if (event.shiftKey) event.preventDefault();
|
|
571
|
+
(
|
|
572
|
+
inputRef.current ??
|
|
573
|
+
comboboxRef.current?.querySelector<HTMLElement>('[role="combobox"]')
|
|
574
|
+
)?.focus();
|
|
575
|
+
// Resume the form's tab order from the trigger, not
|
|
576
|
+
// the portal at the end of document.body.
|
|
577
|
+
if (!event.shiftKey) setOpenState(false);
|
|
578
|
+
}
|
|
579
|
+
}}
|
|
580
|
+
onClick={() => {
|
|
581
|
+
if (!loading) (error ? onRetry : onLoadMore)?.();
|
|
582
|
+
}}
|
|
583
|
+
>
|
|
584
|
+
{error ? 'Try again' : 'Load more results'}
|
|
585
|
+
</Button>
|
|
586
|
+
)}
|
|
587
|
+
</div>
|
|
588
|
+
)}
|
|
490
589
|
</div>
|
|
491
590
|
);
|
|
492
591
|
|
|
493
592
|
if (portal && typeof document !== 'undefined') {
|
|
494
|
-
return createPortal(dropdown,
|
|
593
|
+
return createPortal(dropdown, getPortalContainer(comboboxRef.current));
|
|
495
594
|
}
|
|
496
595
|
|
|
497
596
|
return dropdown;
|
|
@@ -127,7 +127,12 @@ export const Dialog = forwardRef<HTMLDivElement, DialogProps>(
|
|
|
127
127
|
'fixed inset-0 z-[var(--z-modal,3000)] flex items-center justify-center',
|
|
128
128
|
isFullscreenMobile ? 'medium:p-10 p-0' : 'medium:p-10 p-6',
|
|
129
129
|
)}
|
|
130
|
-
role=
|
|
130
|
+
role={role}
|
|
131
|
+
data-unisane-modal-root=""
|
|
132
|
+
aria-modal="true"
|
|
133
|
+
aria-label={ariaLabel}
|
|
134
|
+
aria-labelledby={title ? titleId : undefined}
|
|
135
|
+
aria-describedby={describedBy}
|
|
131
136
|
>
|
|
132
137
|
<div
|
|
133
138
|
className="bg-scrim animate-fade-enter absolute inset-0"
|
|
@@ -137,11 +142,6 @@ export const Dialog = forwardRef<HTMLDivElement, DialogProps>(
|
|
|
137
142
|
|
|
138
143
|
<Surface
|
|
139
144
|
ref={setRefs}
|
|
140
|
-
role={role}
|
|
141
|
-
aria-modal="true"
|
|
142
|
-
aria-label={ariaLabel}
|
|
143
|
-
aria-labelledby={title ? titleId : undefined}
|
|
144
|
-
aria-describedby={describedBy}
|
|
145
145
|
tabIndex={-1}
|
|
146
146
|
tone="surface"
|
|
147
147
|
elevation={5}
|
|
@@ -177,7 +177,7 @@ export const Dialog = forwardRef<HTMLDivElement, DialogProps>(
|
|
|
177
177
|
variant="titleLarge"
|
|
178
178
|
as="div"
|
|
179
179
|
id={titleId}
|
|
180
|
-
className="text-on-surface
|
|
180
|
+
className="text-on-surface wrap-break-word leading-tight"
|
|
181
181
|
>
|
|
182
182
|
{title}
|
|
183
183
|
</Text>
|
|
@@ -187,7 +187,7 @@ export const Dialog = forwardRef<HTMLDivElement, DialogProps>(
|
|
|
187
187
|
variant="bodySmall"
|
|
188
188
|
as="div"
|
|
189
189
|
id={descriptionId}
|
|
190
|
-
className="text-on-surface-variant
|
|
190
|
+
className="text-on-surface-variant wrap-break-word leading-relaxed"
|
|
191
191
|
>
|
|
192
192
|
{description}
|
|
193
193
|
</Text>
|
|
@@ -4,7 +4,11 @@ import React, { useState, useRef, useEffect, useLayoutEffect, useId, useMemo } f
|
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
5
|
import { cn, Slot } from '@/lib/utils';
|
|
6
6
|
import { useControllableState } from '@/lib/use-controllable-state';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
getPortalContainer,
|
|
9
|
+
getPortalLayerStyle,
|
|
10
|
+
observePortalAnchorMotion,
|
|
11
|
+
} from '@/lib/portal-layer';
|
|
8
12
|
import { useOverlayBehavior } from '@/lib/use-overlay-behavior';
|
|
9
13
|
import { Icon } from '@/components/ui/icon';
|
|
10
14
|
import { Menu, MenuItem, MenuDivider, MenuCheckboxItem, MenuRadioItem } from '@/primitives/menu';
|
|
@@ -407,10 +411,12 @@ export const DropdownMenuContent: React.FC<DropdownMenuContentProps> = ({
|
|
|
407
411
|
updatePosition();
|
|
408
412
|
|
|
409
413
|
if (portal) {
|
|
414
|
+
const stopMotion = observePortalAnchorMotion(triggerRef.current, updatePosition);
|
|
410
415
|
window.addEventListener('scroll', updatePosition, true);
|
|
411
416
|
window.addEventListener('resize', updatePosition);
|
|
412
417
|
|
|
413
418
|
return () => {
|
|
419
|
+
stopMotion();
|
|
414
420
|
window.removeEventListener('scroll', updatePosition, true);
|
|
415
421
|
window.removeEventListener('resize', updatePosition);
|
|
416
422
|
};
|
|
@@ -559,7 +565,7 @@ export const DropdownMenuContent: React.FC<DropdownMenuContentProps> = ({
|
|
|
559
565
|
);
|
|
560
566
|
|
|
561
567
|
if (portal && typeof document !== 'undefined') {
|
|
562
|
-
return createPortal(content,
|
|
568
|
+
return createPortal(content, getPortalContainer(triggerRef.current));
|
|
563
569
|
}
|
|
564
570
|
|
|
565
571
|
return content;
|
|
@@ -823,10 +829,12 @@ export const DropdownMenuSubContent: React.FC<DropdownMenuSubContentProps> = ({
|
|
|
823
829
|
setIsPositioned(false);
|
|
824
830
|
updatePosition();
|
|
825
831
|
|
|
832
|
+
const stopMotion = observePortalAnchorMotion(triggerRef.current, updatePosition);
|
|
826
833
|
window.addEventListener('scroll', updatePosition, true);
|
|
827
834
|
window.addEventListener('resize', updatePosition);
|
|
828
835
|
|
|
829
836
|
return () => {
|
|
837
|
+
stopMotion();
|
|
830
838
|
window.removeEventListener('scroll', updatePosition, true);
|
|
831
839
|
window.removeEventListener('resize', updatePosition);
|
|
832
840
|
};
|
|
@@ -872,7 +880,7 @@ export const DropdownMenuSubContent: React.FC<DropdownMenuSubContentProps> = ({
|
|
|
872
880
|
);
|
|
873
881
|
|
|
874
882
|
if (typeof document !== 'undefined') {
|
|
875
|
-
return createPortal(content,
|
|
883
|
+
return createPortal(content, getPortalContainer(triggerRef.current));
|
|
876
884
|
}
|
|
877
885
|
|
|
878
886
|
return content;
|
|
@@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
5
|
import { cn } from '@/lib/utils';
|
|
6
6
|
import { type FieldSize } from '@/lib/field-size';
|
|
7
|
-
import { type FieldShellVariant } from '@/lib/field-shell';
|
|
7
|
+
import { type FieldLabelPlacement, type FieldShellVariant } from '@/lib/field-shell';
|
|
8
8
|
import { getPortalLayerStyle } from '@/lib/portal-layer';
|
|
9
9
|
import { useAnchoredOverlayPosition } from '@/lib/use-anchored-overlay-position';
|
|
10
10
|
import { useControllableState } from '@/lib/use-controllable-state';
|
|
@@ -31,6 +31,7 @@ const MONTHS = [
|
|
|
31
31
|
|
|
32
32
|
export type MonthPickerProps = {
|
|
33
33
|
variant?: FieldShellVariant;
|
|
34
|
+
labelPlacement?: FieldLabelPlacement;
|
|
34
35
|
value?: string;
|
|
35
36
|
defaultValue?: string;
|
|
36
37
|
onValueChange?: (value: string) => void;
|
|
@@ -60,6 +61,7 @@ export const MonthPicker: React.FC<MonthPickerProps> = ({
|
|
|
60
61
|
helperText,
|
|
61
62
|
className,
|
|
62
63
|
variant = 'outlined',
|
|
64
|
+
labelPlacement = 'floating',
|
|
63
65
|
min,
|
|
64
66
|
max,
|
|
65
67
|
size = 'md',
|
|
@@ -208,6 +210,7 @@ export const MonthPicker: React.FC<MonthPickerProps> = ({
|
|
|
208
210
|
{...(error ? { errorMessage: helperText } : { description: helperText })}
|
|
209
211
|
invalid={error}
|
|
210
212
|
label={label}
|
|
213
|
+
labelPlacement={labelPlacement}
|
|
211
214
|
size={size}
|
|
212
215
|
trailingIcon={pickerButton}
|
|
213
216
|
value={inputValue}
|
|
@@ -287,11 +290,11 @@ export const MonthPicker: React.FC<MonthPickerProps> = ({
|
|
|
287
290
|
aria-label={`${month} ${viewYear}`}
|
|
288
291
|
className={cn(
|
|
289
292
|
'text-label-large relative min-h-10 overflow-hidden rounded-sm px-2 transition-colors',
|
|
290
|
-
'focus-visible:ring-focus-ring focus-visible:
|
|
293
|
+
'focus-visible:ring-focus-ring focus-visible:outline-none focus-visible:ring-2',
|
|
291
294
|
selected
|
|
292
295
|
? 'bg-primary text-on-primary'
|
|
293
296
|
: 'text-on-surface hover:bg-state-hover',
|
|
294
|
-
disabledMonth && 'pointer-events-none cursor-not-allowed
|
|
297
|
+
disabledMonth && 'opacity-38 pointer-events-none cursor-not-allowed',
|
|
295
298
|
)}
|
|
296
299
|
onClick={() => selectMonth(monthIndex)}
|
|
297
300
|
>
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useRef, useId, useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getPortalContainer,
|
|
7
|
+
getPortalLayerStyle,
|
|
8
|
+
observePortalAnchorMotion,
|
|
9
|
+
} from '@/lib/portal-layer';
|
|
6
10
|
import { cn, focusRing, Slot } from '@/lib/utils';
|
|
7
11
|
import { useControllableState } from '@/lib/use-controllable-state';
|
|
8
12
|
import { useOverlayBehavior } from '@/lib/use-overlay-behavior';
|
|
@@ -127,10 +131,13 @@ export const Popover: React.FC<PopoverProps> = ({
|
|
|
127
131
|
|
|
128
132
|
if (!portal) return;
|
|
129
133
|
|
|
134
|
+
const stopMotion = observePortalAnchorMotion(triggerRef.current, updatePosition);
|
|
135
|
+
|
|
130
136
|
window.addEventListener('scroll', updatePosition, true);
|
|
131
137
|
window.addEventListener('resize', updatePosition);
|
|
132
138
|
|
|
133
139
|
return () => {
|
|
140
|
+
stopMotion();
|
|
134
141
|
window.removeEventListener('scroll', updatePosition, true);
|
|
135
142
|
window.removeEventListener('resize', updatePosition);
|
|
136
143
|
};
|
|
@@ -284,7 +291,7 @@ function renderPopoverContent({
|
|
|
284
291
|
);
|
|
285
292
|
|
|
286
293
|
if (portal && typeof document !== 'undefined') {
|
|
287
|
-
return createPortal(popover,
|
|
294
|
+
return createPortal(popover, getPortalContainer(trigger));
|
|
288
295
|
}
|
|
289
296
|
|
|
290
297
|
return popover;
|
|
@@ -8,6 +8,8 @@ import { getFieldSizeStyles, type FieldSize } from '@/lib/field-size';
|
|
|
8
8
|
import {
|
|
9
9
|
getFieldHelperTextClasses,
|
|
10
10
|
getFieldLabelClasses,
|
|
11
|
+
getStackedFieldLabelClasses,
|
|
12
|
+
type FieldLabelPlacement,
|
|
11
13
|
type FieldShellVariant,
|
|
12
14
|
} from '@/lib/field-shell';
|
|
13
15
|
import { useControllableState } from '@/lib/use-controllable-state';
|
|
@@ -50,6 +52,7 @@ type SelectFieldCommonProps = {
|
|
|
50
52
|
portal?: boolean;
|
|
51
53
|
size?: FieldSize;
|
|
52
54
|
variant?: FieldShellVariant;
|
|
55
|
+
labelPlacement?: FieldLabelPlacement;
|
|
53
56
|
className?: string;
|
|
54
57
|
triggerClassName?: string;
|
|
55
58
|
contentClassName?: string;
|
|
@@ -86,6 +89,7 @@ export const SelectField = React.forwardRef<HTMLButtonElement, SelectFieldProps>
|
|
|
86
89
|
triggerClassName,
|
|
87
90
|
value,
|
|
88
91
|
variant = 'outlined',
|
|
92
|
+
labelPlacement = 'floating',
|
|
89
93
|
},
|
|
90
94
|
ref,
|
|
91
95
|
) => {
|
|
@@ -109,10 +113,23 @@ export const SelectField = React.forwardRef<HTMLButtonElement, SelectFieldProps>
|
|
|
109
113
|
onChange: onOpenChange,
|
|
110
114
|
});
|
|
111
115
|
const isOpen = openState ?? false;
|
|
112
|
-
const isFloating =
|
|
116
|
+
const isFloating =
|
|
117
|
+
labelPlacement === 'stacked' || hideLabel || isOpen || Boolean(selectedValue);
|
|
113
118
|
|
|
114
119
|
return (
|
|
115
120
|
<Field className={className} invalid={resolvedInvalid}>
|
|
121
|
+
{label && labelPlacement === 'stacked' ? (
|
|
122
|
+
<FieldLabel
|
|
123
|
+
id={labelId}
|
|
124
|
+
htmlFor={fieldId}
|
|
125
|
+
required={required}
|
|
126
|
+
className={
|
|
127
|
+
hideLabel ? 'sr-only' : getStackedFieldLabelClasses(size, resolvedInvalid, disabled)
|
|
128
|
+
}
|
|
129
|
+
>
|
|
130
|
+
{label}
|
|
131
|
+
</FieldLabel>
|
|
132
|
+
) : null}
|
|
116
133
|
<Select
|
|
117
134
|
value={selectedValue}
|
|
118
135
|
onValueChange={setSelectedValue}
|
|
@@ -139,7 +156,7 @@ export const SelectField = React.forwardRef<HTMLButtonElement, SelectFieldProps>
|
|
|
139
156
|
'pointer-events-none flex h-full min-w-0 items-center',
|
|
140
157
|
fieldSize.horizontalPadding,
|
|
141
158
|
fieldSize.valueText,
|
|
142
|
-
variant === 'filled' && label
|
|
159
|
+
variant === 'filled' && labelPlacement === 'floating' && label
|
|
143
160
|
? cn('items-end', fieldSize.filledDisplayPadding)
|
|
144
161
|
: '',
|
|
145
162
|
)}
|
|
@@ -147,7 +164,7 @@ export const SelectField = React.forwardRef<HTMLButtonElement, SelectFieldProps>
|
|
|
147
164
|
<SelectValue placeholder={isFloating || !label ? placeholder : undefined} />
|
|
148
165
|
</span>
|
|
149
166
|
</SelectTrigger>
|
|
150
|
-
{label ? (
|
|
167
|
+
{label && labelPlacement === 'floating' ? (
|
|
151
168
|
<FieldLabel
|
|
152
169
|
id={labelId}
|
|
153
170
|
htmlFor={fieldId}
|
|
@@ -162,7 +179,9 @@ export const SelectField = React.forwardRef<HTMLButtonElement, SelectFieldProps>
|
|
|
162
179
|
error: resolvedInvalid,
|
|
163
180
|
active: isOpen,
|
|
164
181
|
floatingClassName:
|
|
165
|
-
variant === 'filled'
|
|
182
|
+
variant === 'filled' && labelPlacement === 'floating'
|
|
183
|
+
? fieldSize.filledDisplayFloatingLabel
|
|
184
|
+
: undefined,
|
|
166
185
|
})
|
|
167
186
|
}
|
|
168
187
|
>
|
|
@@ -189,11 +208,17 @@ export const SelectField = React.forwardRef<HTMLButtonElement, SelectFieldProps>
|
|
|
189
208
|
</SelectContent>
|
|
190
209
|
</Select>
|
|
191
210
|
{errorMessage ? (
|
|
192
|
-
<FieldError
|
|
211
|
+
<FieldError
|
|
212
|
+
id={errorId}
|
|
213
|
+
className={getFieldHelperTextClasses(size, true, labelPlacement)}
|
|
214
|
+
>
|
|
193
215
|
{errorMessage}
|
|
194
216
|
</FieldError>
|
|
195
217
|
) : description ? (
|
|
196
|
-
<FieldDescription
|
|
218
|
+
<FieldDescription
|
|
219
|
+
id={descriptionId}
|
|
220
|
+
className={getFieldHelperTextClasses(size, false, labelPlacement)}
|
|
221
|
+
>
|
|
197
222
|
{description}
|
|
198
223
|
</FieldDescription>
|
|
199
224
|
) : null}
|
|
@@ -5,7 +5,11 @@ import { createPortal } from 'react-dom';
|
|
|
5
5
|
import { cn } from '@/lib/utils';
|
|
6
6
|
import { getFieldSizeStyles, type FieldSize } from '@/lib/field-size';
|
|
7
7
|
import { fieldContainerVariants, type FieldShellVariant } from '@/lib/field-shell';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getPortalContainer,
|
|
10
|
+
getPortalLayerStyle,
|
|
11
|
+
observePortalAnchorMotion,
|
|
12
|
+
} from '@/lib/portal-layer';
|
|
9
13
|
import { useControllableState } from '@/lib/use-controllable-state';
|
|
10
14
|
import { useOverlayBehavior } from '@/lib/use-overlay-behavior';
|
|
11
15
|
import { Icon } from '@/components/ui/icon';
|
|
@@ -250,7 +254,7 @@ export const Select = React.forwardRef<HTMLDivElement, SelectProps>(
|
|
|
250
254
|
<div
|
|
251
255
|
{...props}
|
|
252
256
|
ref={rootRef}
|
|
253
|
-
className={cn('relative inline-flex w-full min-w-
|
|
257
|
+
className={cn('relative inline-flex w-full min-w-0 flex-col', className)}
|
|
254
258
|
data-disabled={disabled || undefined}
|
|
255
259
|
data-state={isOpen ? 'open' : 'closed'}
|
|
256
260
|
>
|
|
@@ -355,7 +359,7 @@ export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerPr
|
|
|
355
359
|
data-state={context.open ? 'open' : 'closed'}
|
|
356
360
|
disabled={resolvedDisabled}
|
|
357
361
|
className={cn(
|
|
358
|
-
'group relative flex w-full cursor-pointer items-center transition-colors
|
|
362
|
+
'group relative flex w-full cursor-pointer select-none items-center transition-colors',
|
|
359
363
|
fieldSize.containerHeight,
|
|
360
364
|
fieldContainerVariants({ variant, error: invalid, disabled: resolvedDisabled }),
|
|
361
365
|
'focus-within:ring-0',
|
|
@@ -375,18 +379,23 @@ export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerPr
|
|
|
375
379
|
{variant === 'filled' ? (
|
|
376
380
|
<span
|
|
377
381
|
className={cn(
|
|
378
|
-
'duration-snappy absolute
|
|
382
|
+
'duration-snappy absolute bottom-[calc(var(--unit)*-0.25)] left-0 right-0 h-0.5 origin-center scale-x-0 transition-transform ease-out',
|
|
379
383
|
invalid ? 'bg-error scale-x-100' : 'bg-primary',
|
|
380
384
|
context.open && 'scale-x-100',
|
|
381
385
|
)}
|
|
382
386
|
/>
|
|
383
387
|
) : null}
|
|
384
388
|
<span className="min-w-0 flex-1 text-left">{children}</span>
|
|
385
|
-
<span
|
|
389
|
+
<span
|
|
390
|
+
className={cn(
|
|
391
|
+
'text-on-surface-variant flex shrink-0 items-center',
|
|
392
|
+
fieldSize.trailingPadding,
|
|
393
|
+
)}
|
|
394
|
+
>
|
|
386
395
|
{trailingIcon ?? (
|
|
387
396
|
<Icon
|
|
388
397
|
symbol="arrow_drop_down"
|
|
389
|
-
size={size === '
|
|
398
|
+
size={size === 'lg' ? 'md' : 'sm'}
|
|
390
399
|
className={cn(
|
|
391
400
|
'duration-short ease-standard transition-transform',
|
|
392
401
|
context.open && 'rotate-180',
|
|
@@ -504,14 +513,16 @@ export const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps
|
|
|
504
513
|
};
|
|
505
514
|
update();
|
|
506
515
|
const frame = window.requestAnimationFrame(update);
|
|
516
|
+
const stopMotion = observePortalAnchorMotion(context.triggerRef.current, update);
|
|
507
517
|
window.addEventListener('scroll', update, true);
|
|
508
518
|
window.addEventListener('resize', update);
|
|
509
519
|
return () => {
|
|
510
520
|
window.cancelAnimationFrame(frame);
|
|
521
|
+
stopMotion();
|
|
511
522
|
window.removeEventListener('scroll', update, true);
|
|
512
523
|
window.removeEventListener('resize', update);
|
|
513
524
|
};
|
|
514
|
-
}, [context.open, portal, updatePosition]);
|
|
525
|
+
}, [context.open, context.triggerRef, portal, updatePosition]);
|
|
515
526
|
|
|
516
527
|
useOverlayBehavior({
|
|
517
528
|
open: context.open,
|
|
@@ -543,7 +554,7 @@ export const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps
|
|
|
543
554
|
portal
|
|
544
555
|
? 'fixed'
|
|
545
556
|
: cn(
|
|
546
|
-
'absolute
|
|
557
|
+
'absolute left-0 right-0',
|
|
547
558
|
position.direction === 'up'
|
|
548
559
|
? 'bottom-[calc(100%+var(--unit))]'
|
|
549
560
|
: 'top-[calc(100%+var(--unit))]',
|
|
@@ -569,7 +580,7 @@ export const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps
|
|
|
569
580
|
);
|
|
570
581
|
|
|
571
582
|
return portal && context.open && typeof document !== 'undefined'
|
|
572
|
-
? createPortal(content,
|
|
583
|
+
? createPortal(content, getPortalContainer(context.triggerRef.current))
|
|
573
584
|
: content;
|
|
574
585
|
},
|
|
575
586
|
);
|
|
@@ -629,10 +640,10 @@ export const SelectItem = React.forwardRef<HTMLDivElement, SelectItemProps>(
|
|
|
629
640
|
data-highlighted={highlighted || undefined}
|
|
630
641
|
data-state={selected ? 'checked' : 'unchecked'}
|
|
631
642
|
className={cn(
|
|
632
|
-
'text-label-medium relative flex min-h-[calc(var(--unit)*10)] cursor-pointer items-center
|
|
643
|
+
'text-label-medium rounded-xs relative flex min-h-[calc(var(--unit)*10)] cursor-pointer items-center py-2 pl-3 pr-[calc(var(--unit)*10)] font-medium outline-none transition-colors',
|
|
633
644
|
'hover:bg-state-hover data-[highlighted=true]:bg-state-hover',
|
|
634
645
|
selected && 'bg-state-selected text-on-surface',
|
|
635
|
-
resolvedDisabled && 'cursor-not-allowed
|
|
646
|
+
resolvedDisabled && 'opacity-38 cursor-not-allowed',
|
|
636
647
|
className,
|
|
637
648
|
)}
|
|
638
649
|
onClick={(event) => {
|