@unisane/ui 0.1.0 → 0.1.2-next.20261027.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 (65) 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/dist/lib/utils.d.ts.map +1 -1
  48. package/dist/lib/utils.js +3 -1
  49. package/dist/lib/utils.js.map +1 -1
  50. package/package.json +3 -3
  51. package/registry/components/combobox.tsx +118 -19
  52. package/registry/components/dialog.tsx +8 -8
  53. package/registry/components/dropdown-menu.tsx +11 -3
  54. package/registry/components/month-picker.tsx +6 -3
  55. package/registry/components/popover.tsx +9 -2
  56. package/registry/components/select-field.tsx +31 -6
  57. package/registry/components/select.tsx +22 -11
  58. package/registry/components/sheet.tsx +11 -9
  59. package/registry/components/text-field.tsx +75 -24
  60. package/registry/components/token-field.tsx +40 -17
  61. package/registry/lib/field-shell.ts +19 -3
  62. package/registry/lib/portal-layer.ts +47 -1
  63. package/registry/lib/use-overlay-behavior.ts +7 -2
  64. package/registry/lib/utils.ts +3 -1
  65. package/registry/registry.json +1 -0
@@ -99,7 +99,9 @@ export const Sheet = forwardRef<HTMLDivElement, SheetProps>(
99
99
 
100
100
  useScrollLock(isOpen);
101
101
  useOverlayBehavior({
102
- open: isOpen,
102
+ // A controlled open first schedules the deferred panel mount. Register
103
+ // only once its refs exist, including on subsequent closed-to-open cycles.
104
+ open: isOpen && shouldRender,
103
105
  contentRef: panelRef,
104
106
  rootRef: overlayRootRef,
105
107
  onDismiss: () => setIsOpen(false),
@@ -173,7 +175,11 @@ export const Sheet = forwardRef<HTMLDivElement, SheetProps>(
173
175
  ? 'items-stretch justify-start p-0'
174
176
  : 'items-stretch justify-end p-0',
175
177
  )}
176
- role="presentation"
178
+ role="dialog"
179
+ data-unisane-modal-root=""
180
+ aria-modal="true"
181
+ aria-labelledby={title ? titleId : undefined}
182
+ aria-describedby={describedBy}
177
183
  >
178
184
  <div
179
185
  className={cn(
@@ -202,7 +208,7 @@ export const Sheet = forwardRef<HTMLDivElement, SheetProps>(
202
208
  className={cn(
203
209
  'bg-surface shadow-5 border-outline-soft relative flex max-h-full w-full transform-gpu flex-col overflow-hidden border',
204
210
  isBottom
205
- ? 'rounded-t-lg rounded-b-none border-b-0'
211
+ ? 'rounded-b-none rounded-t-lg border-b-0'
206
212
  : isLeft
207
213
  ? 'h-full rounded-none border-l-0'
208
214
  : 'h-full rounded-none border-r-0',
@@ -226,10 +232,6 @@ export const Sheet = forwardRef<HTMLDivElement, SheetProps>(
226
232
  ? 'cubic-bezier(0.2, 0.8, 0.2, 1)'
227
233
  : 'cubic-bezier(0.4, 0, 1, 1)',
228
234
  }}
229
- role="dialog"
230
- aria-modal="true"
231
- aria-labelledby={title ? titleId : undefined}
232
- aria-describedby={describedBy}
233
235
  tabIndex={-1}
234
236
  >
235
237
  {hasHeader ? (
@@ -254,7 +256,7 @@ export const Sheet = forwardRef<HTMLDivElement, SheetProps>(
254
256
  as="div"
255
257
  variant="titleLarge"
256
258
  id={titleId}
257
- className="text-on-surface leading-tight wrap-break-word"
259
+ className="text-on-surface wrap-break-word leading-tight"
258
260
  >
259
261
  {title}
260
262
  </Text>
@@ -264,7 +266,7 @@ export const Sheet = forwardRef<HTMLDivElement, SheetProps>(
264
266
  as="div"
265
267
  variant="bodySmall"
266
268
  id={descriptionId}
267
- className="text-on-surface-variant leading-relaxed wrap-break-word"
269
+ className="text-on-surface-variant wrap-break-word leading-relaxed"
268
270
  >
269
271
  {description}
270
272
  </Text>
@@ -9,6 +9,8 @@ import {
9
9
  getFieldAffixClasses,
10
10
  getFieldHelperTextClasses,
11
11
  getFieldLabelClasses,
12
+ getStackedFieldLabelClasses,
13
+ type FieldLabelPlacement,
12
14
  type FieldShellVariant,
13
15
  } from '@/lib/field-shell';
14
16
 
@@ -30,6 +32,7 @@ type TextFieldCommonProps = {
30
32
  trailingIcon?: React.ReactNode;
31
33
  value?: string | number;
32
34
  variant?: FieldShellVariant;
35
+ labelPlacement?: FieldLabelPlacement;
33
36
  };
34
37
 
35
38
  type ReplacedNativeProps =
@@ -80,6 +83,7 @@ export const TextField = React.forwardRef<TextFieldControl, TextFieldProps>(
80
83
  trailingIcon,
81
84
  value,
82
85
  variant = 'outlined',
86
+ labelPlacement = 'floating',
83
87
  ...nativeControlProps
84
88
  } = props;
85
89
  const inputProps = nativeControlProps as TextFieldInputAttributes & TextFieldEvents;
@@ -102,7 +106,8 @@ export const TextField = React.forwardRef<TextFieldControl, TextFieldProps>(
102
106
  const [uncontrolledValue, setUncontrolledValue] = React.useState(String(defaultValue));
103
107
  const currentValue = value === undefined ? uncontrolledValue : String(value);
104
108
  const hasValue = currentValue.length > 0;
105
- const isFloating = hideLabel || Boolean(placeholder) || isFocused || hasValue;
109
+ const isFloating =
110
+ labelPlacement === 'stacked' || hideLabel || Boolean(placeholder) || isFocused || hasValue;
106
111
 
107
112
  const setControlRef = React.useCallback(
108
113
  (node: TextFieldControl | null) => {
@@ -118,7 +123,33 @@ export const TextField = React.forwardRef<TextFieldControl, TextFieldProps>(
118
123
 
119
124
  React.useLayoutEffect(() => {
120
125
  if (!multiline || !autoResize) return;
121
- resizeTextarea(internalRef.current, autoResizeMaxHeight);
126
+ const field = internalRef.current;
127
+ if (!(field instanceof HTMLTextAreaElement)) return;
128
+ resizeTextarea(field, autoResizeMaxHeight);
129
+ let active = true;
130
+ let width = field.clientWidth;
131
+ const resizeVisible = () => {
132
+ if (active && field.clientWidth > 0) resizeTextarea(field, autoResizeMaxHeight);
133
+ };
134
+ const observer =
135
+ typeof ResizeObserver === 'undefined'
136
+ ? undefined
137
+ : new ResizeObserver(() => {
138
+ if (field.clientWidth !== width) {
139
+ width = field.clientWidth;
140
+ resizeVisible();
141
+ }
142
+ });
143
+ observer?.observe(field);
144
+ // Initial hydration can precede web-font metrics; a hidden panel can also
145
+ // become visible at a different width without changing its text value.
146
+ void document.fonts?.ready.then(resizeVisible);
147
+ document.fonts?.addEventListener('loadingdone', resizeVisible);
148
+ return () => {
149
+ active = false;
150
+ observer?.disconnect();
151
+ document.fonts?.removeEventListener('loadingdone', resizeVisible);
152
+ };
122
153
  }, [autoResize, autoResizeMaxHeight, currentValue, multiline]);
123
154
 
124
155
  function handleValueChange(nextValue: string) {
@@ -133,11 +164,21 @@ export const TextField = React.forwardRef<TextFieldControl, TextFieldProps>(
133
164
  'placeholder:text-on-surface-variant',
134
165
  fieldSize.horizontalPadding,
135
166
  fieldSize.valueText,
136
- variant === 'filled' ? fieldSize.filledInputPadding : '',
167
+ labelPlacement === 'stacked' && 'tracking-normal',
168
+ variant === 'filled' && labelPlacement === 'floating' ? fieldSize.filledInputPadding : '',
137
169
  );
138
170
 
139
171
  return (
140
172
  <Field className={className} invalid={resolvedInvalid}>
173
+ {labelPlacement === 'stacked' && !hideLabel ? (
174
+ <FieldLabel
175
+ htmlFor={fieldId}
176
+ required={required}
177
+ className={getStackedFieldLabelClasses(size, resolvedInvalid, disabled)}
178
+ >
179
+ {label}
180
+ </FieldLabel>
181
+ ) : null}
141
182
  <div
142
183
  className={cn(
143
184
  fieldContainerVariants({ variant, error: resolvedInvalid, disabled }),
@@ -173,7 +214,9 @@ export const TextField = React.forwardRef<TextFieldControl, TextFieldProps>(
173
214
  controlClasses,
174
215
  'min-h-24 resize-none',
175
216
  fieldSize.multilinePaddingY,
176
- variant === 'filled' ? fieldSize.filledTextareaPadding : '',
217
+ variant === 'filled' && labelPlacement === 'floating'
218
+ ? fieldSize.filledTextareaPadding
219
+ : '',
177
220
  )}
178
221
  onBlur={(event) => {
179
222
  setIsFocused(false);
@@ -209,24 +252,26 @@ export const TextField = React.forwardRef<TextFieldControl, TextFieldProps>(
209
252
  }}
210
253
  />
211
254
  )}
212
- <FieldLabel
213
- htmlFor={fieldId}
214
- required={required}
215
- className={
216
- hideLabel
217
- ? 'sr-only'
218
- : getFieldLabelClasses({
219
- size,
220
- variant,
221
- floating: isFloating,
222
- error: resolvedInvalid,
223
- active: isFocused,
224
- multiline,
225
- })
226
- }
227
- >
228
- {label}
229
- </FieldLabel>
255
+ {labelPlacement === 'floating' || hideLabel ? (
256
+ <FieldLabel
257
+ htmlFor={fieldId}
258
+ required={required}
259
+ className={
260
+ hideLabel || labelPlacement === 'stacked'
261
+ ? 'sr-only'
262
+ : getFieldLabelClasses({
263
+ size,
264
+ variant,
265
+ floating: isFloating,
266
+ error: resolvedInvalid,
267
+ active: isFocused,
268
+ multiline,
269
+ })
270
+ }
271
+ >
272
+ {label}
273
+ </FieldLabel>
274
+ ) : null}
230
275
  </div>
231
276
  {trailingIcon ? (
232
277
  <span
@@ -244,11 +289,17 @@ export const TextField = React.forwardRef<TextFieldControl, TextFieldProps>(
244
289
  ) : null}
245
290
  </div>
246
291
  {errorMessage ? (
247
- <FieldError id={errorId} className={getFieldHelperTextClasses(size, true)}>
292
+ <FieldError
293
+ id={errorId}
294
+ className={getFieldHelperTextClasses(size, true, labelPlacement)}
295
+ >
248
296
  {errorMessage}
249
297
  </FieldError>
250
298
  ) : description ? (
251
- <FieldDescription id={descriptionId} className={getFieldHelperTextClasses(size)}>
299
+ <FieldDescription
300
+ id={descriptionId}
301
+ className={getFieldHelperTextClasses(size, false, labelPlacement)}
302
+ >
252
303
  {description}
253
304
  </FieldDescription>
254
305
  ) : null}
@@ -17,6 +17,8 @@ import {
17
17
  getFieldAffixClasses,
18
18
  getFieldHelperTextClasses,
19
19
  getFieldLabelClasses,
20
+ getStackedFieldLabelClasses,
21
+ type FieldLabelPlacement,
20
22
  type FieldShellVariant,
21
23
  } from '@/lib/field-shell';
22
24
  import { useFieldState } from '@/lib/use-field-state';
@@ -32,6 +34,7 @@ export type TokenFieldProps = TokenFieldInputProps & {
32
34
  onValueChange?: (value: string[]) => void;
33
35
  label: string;
34
36
  variant?: FieldShellVariant;
37
+ labelPlacement?: FieldLabelPlacement;
35
38
  size?: FieldSize;
36
39
  error?: boolean;
37
40
  helperText?: string;
@@ -57,6 +60,7 @@ export const TokenField = React.forwardRef<HTMLDivElement, TokenFieldProps>(
57
60
  onValueChange,
58
61
  label,
59
62
  variant = 'outlined',
63
+ labelPlacement = 'floating',
60
64
  size = 'md',
61
65
  error,
62
66
  helperText,
@@ -277,6 +281,17 @@ export const TokenField = React.forwardRef<HTMLDivElement, TokenFieldProps>(
277
281
  className={cn('relative inline-flex w-full flex-col', className)}
278
282
  onBlur={handleRootBlur}
279
283
  >
284
+ {labelPlacement === 'stacked' ? (
285
+ <label
286
+ htmlFor={fieldId}
287
+ className={cn(
288
+ getStackedFieldLabelClasses(size, effectiveError, disabled),
289
+ labelClassName,
290
+ )}
291
+ >
292
+ {label}
293
+ </label>
294
+ ) : null}
280
295
  <div
281
296
  className={cn(
282
297
  fieldContainerVariants({ variant, error: effectiveError, disabled }),
@@ -306,7 +321,10 @@ export const TokenField = React.forwardRef<HTMLDivElement, TokenFieldProps>(
306
321
  className={cn(
307
322
  'flex min-h-full w-full flex-wrap items-center gap-1.5',
308
323
  fieldSize.horizontalPadding,
309
- getTokenFieldContentPadding(size, variant),
324
+ getTokenFieldContentPadding(
325
+ size,
326
+ labelPlacement === 'stacked' ? 'outlined' : variant,
327
+ ),
310
328
  )}
311
329
  >
312
330
  {tokens.length ? (
@@ -362,21 +380,23 @@ export const TokenField = React.forwardRef<HTMLDivElement, TokenFieldProps>(
362
380
  {...props}
363
381
  />
364
382
  </div>
365
- <label
366
- htmlFor={fieldId}
367
- className={getFieldLabelClasses({
368
- size,
369
- variant,
370
- floating: isFloating,
371
- error: effectiveError,
372
- active: isFocused,
373
- multiline: true,
374
- labelBg,
375
- labelClassName,
376
- })}
377
- >
378
- {label}
379
- </label>
383
+ {labelPlacement === 'floating' ? (
384
+ <label
385
+ htmlFor={fieldId}
386
+ className={getFieldLabelClasses({
387
+ size,
388
+ variant,
389
+ floating: isFloating,
390
+ error: effectiveError,
391
+ active: isFocused,
392
+ multiline: true,
393
+ labelBg,
394
+ labelClassName,
395
+ })}
396
+ >
397
+ {label}
398
+ </label>
399
+ ) : null}
380
400
  </div>
381
401
  {trailingIcon && (
382
402
  <span
@@ -394,7 +414,10 @@ export const TokenField = React.forwardRef<HTMLDivElement, TokenFieldProps>(
394
414
  )}
395
415
  </div>
396
416
  {effectiveHelperText && (
397
- <span id={helperId} className={getFieldHelperTextClasses(size, effectiveError)}>
417
+ <span
418
+ id={helperId}
419
+ className={getFieldHelperTextClasses(size, effectiveError, labelPlacement)}
420
+ >
398
421
  {effectiveHelperText}
399
422
  </span>
400
423
  )}
@@ -5,6 +5,16 @@ import { cn } from '@/lib/utils';
5
5
  import { getFieldSizeStyles, type FieldSize } from '@/lib/field-size';
6
6
 
7
7
  export type FieldShellVariant = 'outlined' | 'filled';
8
+ export type FieldLabelPlacement = 'floating' | 'stacked';
9
+
10
+ export function getStackedFieldLabelClasses(size: FieldSize, error = false, disabled = false) {
11
+ return cn(
12
+ 'mb-2 block font-semibold tracking-normal',
13
+ getFieldSizeStyles(size).externalLabelText,
14
+ error ? 'text-error' : 'text-on-surface',
15
+ disabled && 'opacity-38',
16
+ );
17
+ }
8
18
 
9
19
  export const fieldContainerVariants = cva(
10
20
  'relative flex w-full transition-all duration-snappy ease-emphasized group cursor-text',
@@ -103,13 +113,19 @@ export function getFieldAffixClasses({
103
113
  );
104
114
  }
105
115
 
106
- export function getFieldHelperTextClasses(size: FieldSize = 'md', error = false) {
116
+ export function getFieldHelperTextClasses(
117
+ size: FieldSize = 'md',
118
+ error = false,
119
+ labelPlacement: FieldLabelPlacement = 'floating',
120
+ ) {
107
121
  const fieldSize = getFieldSizeStyles(size);
108
122
 
109
123
  return cn(
110
- 'text-label-small font-medium',
124
+ labelPlacement === 'stacked'
125
+ ? 'text-body-small font-normal tracking-normal'
126
+ : 'text-label-small font-medium',
111
127
  fieldSize.helperMarginTop,
112
- fieldSize.helperPaddingX,
128
+ labelPlacement === 'floating' && fieldSize.helperPaddingX,
113
129
  error ? 'text-error' : 'text-on-surface-variant',
114
130
  );
115
131
  }
@@ -1,6 +1,52 @@
1
1
  import type React from 'react';
2
2
 
3
- const MODAL_LAYER_SELECTOR = '[role="dialog"][aria-modal="true"]';
3
+ const MODAL_LAYER_SELECTOR =
4
+ '[role="dialog"][aria-modal="true"], [role="alertdialog"][aria-modal="true"]';
5
+
6
+ // Keep floating controls in the modal's accessible subtree. Shared modals put
7
+ // this boundary outside their animated/clipped panel so fixed coordinates stay
8
+ // viewport-relative, including in fullscreen and bottom-sheet presentations.
9
+ export function getPortalContainer(anchor: HTMLElement | null): HTMLElement {
10
+ const modal = anchor?.closest<HTMLElement>(MODAL_LAYER_SELECTOR);
11
+ return modal?.hasAttribute('data-unisane-modal-root') ? modal : document.body;
12
+ }
13
+
14
+ /** Follow an opening panel without a permanent per-frame positioning loop. */
15
+ export function observePortalAnchorMotion(anchor: HTMLElement | null, update: () => void) {
16
+ let frame: number | undefined;
17
+ const ancestors: HTMLElement[] = [];
18
+ for (let node = anchor; node; node = node.parentElement) ancestors.push(node);
19
+ const tick = () => {
20
+ frame = undefined;
21
+ update();
22
+ if (
23
+ ancestors.some((node) =>
24
+ node.getAnimations?.().some((animation) => animation.playState === 'running'),
25
+ )
26
+ ) {
27
+ frame = window.requestAnimationFrame(tick);
28
+ }
29
+ };
30
+ const onMotion = (event: Event) => {
31
+ if (!(event.target instanceof HTMLElement) || !ancestors.includes(event.target)) return;
32
+ if (frame !== undefined) window.cancelAnimationFrame(frame);
33
+ frame = window.requestAnimationFrame(tick);
34
+ };
35
+ const events = [
36
+ 'animationstart',
37
+ 'animationend',
38
+ 'animationcancel',
39
+ 'transitionrun',
40
+ 'transitionend',
41
+ 'transitioncancel',
42
+ ];
43
+ events.forEach((event) => window.addEventListener(event, onMotion, true));
44
+ frame = window.requestAnimationFrame(tick);
45
+ return () => {
46
+ if (frame !== undefined) window.cancelAnimationFrame(frame);
47
+ events.forEach((event) => window.removeEventListener(event, onMotion, true));
48
+ };
49
+ }
4
50
 
5
51
  export function getPortalLayerStyle(anchor: HTMLElement | null): React.CSSProperties | undefined {
6
52
  if (!anchor) {
@@ -231,7 +231,10 @@ export function useOverlayBehavior({
231
231
  : undefined;
232
232
 
233
233
  const handleKeyDown = (event: KeyboardEvent) => {
234
- if (!isTopmostLayer(layer)) {
234
+ // A nested widget may already have handled Escape/Tab and synchronously
235
+ // unmounted its layer before this document listener runs. Do not consume
236
+ // that same key again in the newly exposed parent modal.
237
+ if (event.defaultPrevented || !isTopmostLayer(layer)) {
235
238
  return;
236
239
  }
237
240
 
@@ -245,7 +248,9 @@ export function useOverlayBehavior({
245
248
  return;
246
249
  }
247
250
 
248
- const focusableElements = getFocusableElements(content);
251
+ // Modal-owned floating controls portal beside the visual panel, inside
252
+ // the semantic root. Include them in the same keyboard boundary.
253
+ const focusableElements = getFocusableElements(modal ? layer.root : content);
249
254
  if (focusableElements.length === 0) {
250
255
  event.preventDefault();
251
256
  content.focus();
@@ -168,7 +168,9 @@ export const Slot = React.forwardRef<HTMLElement, SlotProps>(({ children, ...pro
168
168
  }
169
169
 
170
170
  // Development warning for invalid children
171
- if (process.env.NODE_ENV !== 'production') {
171
+ const nodeEnvironment = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env
172
+ ?.NODE_ENV;
173
+ if (nodeEnvironment !== 'production') {
172
174
  const childType =
173
175
  children === null
174
176
  ? 'null'
@@ -407,6 +407,7 @@
407
407
  ],
408
408
  "dependencies": [],
409
409
  "registryDependencies": [
410
+ "button",
410
411
  "field-shell",
411
412
  "field-size",
412
413
  "icon",