@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.
Files changed (61) hide show
  1. package/dist/components/combobox.d.ts +8 -0
  2. package/dist/components/combobox.d.ts.map +1 -1
  3. package/dist/components/combobox.js +60 -13
  4. package/dist/components/combobox.js.map +1 -1
  5. package/dist/components/dialog.js +2 -2
  6. package/dist/components/dialog.js.map +1 -1
  7. package/dist/components/dropdown-menu.d.ts.map +1 -1
  8. package/dist/components/dropdown-menu.js +7 -3
  9. package/dist/components/dropdown-menu.js.map +1 -1
  10. package/dist/components/month-picker.d.ts +2 -1
  11. package/dist/components/month-picker.d.ts.map +1 -1
  12. package/dist/components/month-picker.js +4 -4
  13. package/dist/components/month-picker.js.map +1 -1
  14. package/dist/components/popover.d.ts.map +1 -1
  15. package/dist/components/popover.js +4 -2
  16. package/dist/components/popover.js.map +1 -1
  17. package/dist/components/select-field.d.ts +2 -1
  18. package/dist/components/select-field.d.ts.map +1 -1
  19. package/dist/components/select-field.js +9 -7
  20. package/dist/components/select-field.js.map +1 -1
  21. package/dist/components/select.d.ts.map +1 -1
  22. package/dist/components/select.js +10 -8
  23. package/dist/components/select.js.map +1 -1
  24. package/dist/components/sheet.d.ts.map +1 -1
  25. package/dist/components/sheet.js +6 -4
  26. package/dist/components/sheet.js.map +1 -1
  27. package/dist/components/text-field.d.ts +2 -1
  28. package/dist/components/text-field.d.ts.map +1 -1
  29. package/dist/components/text-field.js +39 -10
  30. package/dist/components/text-field.js.map +1 -1
  31. package/dist/components/token-field.d.ts +3 -1
  32. package/dist/components/token-field.d.ts.map +1 -1
  33. package/dist/components/token-field.js +7 -7
  34. package/dist/components/token-field.js.map +1 -1
  35. package/dist/index.css +5 -0
  36. package/dist/lib/field-shell.d.ts +3 -1
  37. package/dist/lib/field-shell.d.ts.map +1 -1
  38. package/dist/lib/field-shell.js +7 -2
  39. package/dist/lib/field-shell.js.map +1 -1
  40. package/dist/lib/portal-layer.d.ts +3 -0
  41. package/dist/lib/portal-layer.d.ts.map +1 -1
  42. package/dist/lib/portal-layer.js +44 -1
  43. package/dist/lib/portal-layer.js.map +1 -1
  44. package/dist/lib/use-overlay-behavior.d.ts.map +1 -1
  45. package/dist/lib/use-overlay-behavior.js +7 -2
  46. package/dist/lib/use-overlay-behavior.js.map +1 -1
  47. package/package.json +3 -3
  48. package/registry/components/combobox.tsx +118 -19
  49. package/registry/components/dialog.tsx +8 -8
  50. package/registry/components/dropdown-menu.tsx +11 -3
  51. package/registry/components/month-picker.tsx +6 -3
  52. package/registry/components/popover.tsx +9 -2
  53. package/registry/components/select-field.tsx +31 -6
  54. package/registry/components/select.tsx +22 -11
  55. package/registry/components/sheet.tsx +11 -9
  56. package/registry/components/text-field.tsx +75 -24
  57. package/registry/components/token-field.tsx +40 -17
  58. package/registry/lib/field-shell.ts +19 -3
  59. package/registry/lib/portal-layer.ts +47 -1
  60. package/registry/lib/use-overlay-behavior.ts +7 -2
  61. 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 { getPortalLayerStyle } from '@/lib/portal-layer';
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 = options.filter((option) =>
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 (!comboboxRef.current?.contains(document.activeElement)) {
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.horizontalPadding,
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
- onFocus={() => {
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="sm"
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))] right-0 left-0',
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
- role="listbox"
441
- id={listboxId}
442
- aria-label={label || 'Options'}
493
+ onBlur={handleBlur}
443
494
  >
444
- <div className="py-1">
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 opacity-38',
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
- No results found
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, document.body);
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="presentation"
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 leading-tight wrap-break-word"
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 leading-relaxed wrap-break-word"
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 { getPortalLayerStyle } from '@/lib/portal-layer';
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, document.body);
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, document.body);
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:ring-2 focus-visible:outline-none',
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 opacity-38',
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 { getPortalLayerStyle } from '@/lib/portal-layer';
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, document.body);
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 = hideLabel || isOpen || Boolean(selectedValue);
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' ? fieldSize.filledDisplayFloatingLabel : undefined,
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 id={errorId} className={getFieldHelperTextClasses(size, true)}>
211
+ <FieldError
212
+ id={errorId}
213
+ className={getFieldHelperTextClasses(size, true, labelPlacement)}
214
+ >
193
215
  {errorMessage}
194
216
  </FieldError>
195
217
  ) : description ? (
196
- <FieldDescription id={descriptionId} className={getFieldHelperTextClasses(size)}>
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 { getPortalLayerStyle } from '@/lib/portal-layer';
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-40 flex-col', className)}
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 select-none',
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 right-0 bottom-[calc(var(--unit)*-0.25)] left-0 h-0.5 origin-center scale-x-0 transition-transform ease-out',
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 className={cn('text-on-surface-variant shrink-0', fieldSize.chevronOffset)}>
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 === 'sm' ? 'sm' : 'md'}
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 right-0 left-0',
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, document.body)
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 rounded-xs py-2 pr-[calc(var(--unit)*10)] pl-3 font-medium transition-colors outline-none',
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 opacity-38',
646
+ resolvedDisabled && 'opacity-38 cursor-not-allowed',
636
647
  className,
637
648
  )}
638
649
  onClick={(event) => {