@passui/react 0.1.35 → 0.1.36

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.
@@ -1,6 +1,7 @@
1
1
  import { type FormEvent, type ReactNode } from "react";
2
2
  export type DialogPresentation = "drawer" | "center" | "inline";
3
3
  export type DialogTone = "default" | "destructive";
4
+ export type DialogSize = "sm" | "md" | "lg";
4
5
  export type DialogProps = {
5
6
  open: boolean;
6
7
  onOpenChange: (open: boolean) => void;
@@ -10,6 +11,8 @@ export type DialogProps = {
10
11
  footer?: ReactNode;
11
12
  /** `drawer` (responsive sheet), `center` (floating panel), or `inline`. @default "drawer" */
12
13
  presentation?: DialogPresentation;
14
+ /** Title, description, body and footer density. @default "md" */
15
+ size?: DialogSize;
13
16
  className?: string;
14
17
  onSubmit?: (event: FormEvent<HTMLFormElement>) => void;
15
18
  };
@@ -21,7 +24,7 @@ type ActionsProps = {
21
24
  tone?: DialogTone;
22
25
  onConfirm?: () => void;
23
26
  };
24
- export declare function Dialog({ open, onOpenChange, title, description, children, footer, presentation, className, onSubmit, }: DialogProps): import("react").JSX.Element;
27
+ export declare function Dialog({ open, onOpenChange, title, description, children, footer, presentation, size, className, onSubmit, }: DialogProps): import("react").JSX.Element;
25
28
  export declare namespace Dialog {
26
29
  export { Actions };
27
30
  }
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
+ export type DisclosureGroupSize = "sm" | "md" | "lg";
2
3
  export interface DisclosureGroupProps {
3
4
  label: string;
4
5
  open: boolean;
@@ -10,6 +11,8 @@ export interface DisclosureGroupProps {
10
11
  triggerClassName?: string;
11
12
  /** Supporting text beneath the title. */
12
13
  description?: string;
14
+ /** Trigger, description and content density. @default "md" */
15
+ size?: DisclosureGroupSize;
13
16
  }
14
17
  /** A controlled, accessible disclosure row with the shared inline reveal. */
15
- export declare function DisclosureGroup({ label, open, onOpenChange, children, disabled, className, triggerClassName, description, }: DisclosureGroupProps): import("react").JSX.Element;
18
+ export declare function DisclosureGroup({ label, open, onOpenChange, children, disabled, className, triggerClassName, description, size, }: DisclosureGroupProps): import("react").JSX.Element;
@@ -0,0 +1,2 @@
1
+ export * from "./morph";
2
+ export * from "./use-morph-disclosure";
@@ -0,0 +1,29 @@
1
+ import { type HTMLMotionProps } from "motion/react";
2
+ import { type ReactNode, type RefObject } from "react";
3
+ export interface MorphProps extends Omit<HTMLMotionProps<"div">, "children"> {
4
+ /** Controls whether the overlay is presented. */
5
+ open: boolean;
6
+ /** Called on Escape or outside press. */
7
+ onClose: () => void;
8
+ /** The element whose rect the overlay grows from and collapses back to. */
9
+ sourceRef: RefObject<HTMLElement | null>;
10
+ /** Accessible name (used when there is no labelledBy). */
11
+ label: string;
12
+ /** ID of a visible heading inside the overlay. */
13
+ labelledBy?: string;
14
+ /** Allow tapping the backdrop to close. Escape still calls onClose. @default true */
15
+ dismissOnOutsidePress?: boolean;
16
+ className?: string;
17
+ children?: ReactNode;
18
+ }
19
+ /**
20
+ * A modal overlay that morphs its glass material from a source element's
21
+ * rect to its own resting rect, then collapses back — Apple's
22
+ * matched-geometry effect. The source is concealed while the overlay is
23
+ * mounted so the material reads as the source itself springing open.
24
+ *
25
+ * Reuses `SheetPortal` (theme inheritance), `useSheetFocus` (focus trap,
26
+ * Escape, modal environment) and the same `menuSurfaceReveal` FLIP
27
+ * primitive that powers anchored `Popover` and `ContextMenu`.
28
+ */
29
+ export declare function Morph({ open, ...props }: MorphProps): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Disclosure state that keeps the overlay mounted through its exit animation,
3
+ * mirroring `useAnchoredDisclosure`. Pair with a `useRef<HTMLElement>(null)`
4
+ * for the source.
5
+ */
6
+ export declare function useMorphDisclosure(disabled?: boolean): {
7
+ open: boolean;
8
+ mounted: boolean;
9
+ show: () => void;
10
+ toggle: () => void;
11
+ close: () => void;
12
+ onExited: () => void;
13
+ };
@@ -1,3 +1,4 @@
1
+ export type RadioGroupSize = "sm" | "md" | "lg";
1
2
  export interface RadioGroupOption<T extends string> {
2
3
  value: T;
3
4
  label: string;
@@ -12,6 +13,8 @@ export interface RadioGroupProps<T extends string> {
12
13
  disabled?: boolean;
13
14
  name?: string;
14
15
  className?: string;
16
+ /** Legend, row, radio glyph and description density. @default "md" */
17
+ size?: RadioGroupSize;
15
18
  }
16
19
  /** A labelled choice group using the browser's native radio keyboard behavior. */
17
- export declare function RadioGroup<T extends string>({ value, onChange, label, options, disabled, name, className, }: RadioGroupProps<T>): import("react").JSX.Element;
20
+ export declare function RadioGroup<T extends string>({ value, onChange, label, options, disabled, name, className, size, }: RadioGroupProps<T>): import("react").JSX.Element;
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  export type SheetPlacement = "adaptive" | "center";
3
+ export type SheetSize = "sm" | "md" | "lg";
3
4
  export interface SheetProps {
4
5
  /** Controls whether the sheet is presented. */
5
6
  open: boolean;
@@ -13,6 +14,8 @@ export interface SheetProps {
13
14
  labelledBy?: string;
14
15
  /** `adaptive` rises from the bottom on phones; `center` always floats. @default "adaptive" */
15
16
  placement?: SheetPlacement;
17
+ /** Outer padding and panel max-width. @default "md" */
18
+ size?: SheetSize;
16
19
  className?: string;
17
20
  children: ReactNode;
18
21
  }
@@ -6,6 +6,7 @@ export interface SwatchPickerOption<T extends string> {
6
6
  /** Color of the selected checkmark. */
7
7
  ink: string;
8
8
  }
9
+ export type SwatchPickerSize = "sm" | "md" | "lg";
9
10
  export interface SwatchPickerProps<T extends string> {
10
11
  value: T;
11
12
  options: ReadonlyArray<SwatchPickerOption<T>>;
@@ -14,6 +15,8 @@ export interface SwatchPickerProps<T extends string> {
14
15
  label: string;
15
16
  /** Custom classes for the swatch container. */
16
17
  className?: string;
18
+ /** Swatch diameter, check icon and caption scale. @default "md" */
19
+ size?: SwatchPickerSize;
17
20
  }
18
21
  /** Colour swatches with a check on the chosen one. */
19
- export declare function SwatchPicker<T extends string>({ value, options, onChange, label, className, }: SwatchPickerProps<T>): import("react").JSX.Element;
22
+ export declare function SwatchPicker<T extends string>({ value, options, onChange, label, className, size, }: SwatchPickerProps<T>): import("react").JSX.Element;
@@ -69,3 +69,4 @@ export * from "./components/slosh-slider/slosh-slider";
69
69
  export * from "./components/wake-slider/wake-slider";
70
70
  export * from "./components/one-time-code/one-time-code";
71
71
  export * from "./components/aspect-ratio/aspect-ratio";
72
+ export * from "./components/morph";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passui/react",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "Passui: native React controls, design tokens, presentation and appearance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -62,6 +62,10 @@
62
62
  "types": "./dist/types/src/components/popover/popover.d.ts",
63
63
  "default": "./src/components/popover/popover.tsx"
64
64
  },
65
+ "./morph": {
66
+ "types": "./dist/types/src/components/morph/index.d.ts",
67
+ "default": "./src/components/morph/index.ts"
68
+ },
65
69
  "./sheet": {
66
70
  "types": "./dist/types/src/components/sheet/sheet.d.ts",
67
71
  "default": "./src/components/sheet/sheet.tsx"
@@ -8,6 +8,28 @@ import { createContext, useContext, useId, type FormEvent, type ReactNode } from
8
8
  const CloseContext = createContext<() => void>(() => {})
9
9
  export type DialogPresentation = "drawer" | "center" | "inline"
10
10
  export type DialogTone = "default" | "destructive"
11
+ export type DialogSize = "sm" | "md" | "lg"
12
+
13
+ const titleText: Record<DialogSize, string> = {
14
+ sm: "text-subheadline",
15
+ md: "text-title3",
16
+ lg: "text-title2",
17
+ }
18
+ const descriptionText: Record<DialogSize, string> = {
19
+ sm: "text-caption",
20
+ md: "text-footnote",
21
+ lg: "text-subheadline",
22
+ }
23
+ const bodySpacing: Record<DialogSize, string> = {
24
+ sm: "gap-3 p-4",
25
+ md: "gap-4 p-5",
26
+ lg: "gap-5 p-6",
27
+ }
28
+ const footerSpacing: Record<DialogSize, string> = {
29
+ sm: "pt-2",
30
+ md: "pt-3",
31
+ lg: "pt-4",
32
+ }
11
33
 
12
34
  export type DialogProps = {
13
35
  open: boolean
@@ -18,6 +40,8 @@ export type DialogProps = {
18
40
  footer?: ReactNode
19
41
  /** `drawer` (responsive sheet), `center` (floating panel), or `inline`. @default "drawer" */
20
42
  presentation?: DialogPresentation
43
+ /** Title, description, body and footer density. @default "md" */
44
+ size?: DialogSize
21
45
  className?: string
22
46
  onSubmit?: (event: FormEvent<HTMLFormElement>) => void
23
47
  }
@@ -38,6 +62,7 @@ export function Dialog({
38
62
  children,
39
63
  footer,
40
64
  presentation = "drawer",
65
+ size = "md",
41
66
  className,
42
67
  onSubmit,
43
68
  }: DialogProps) {
@@ -47,13 +72,21 @@ export function Dialog({
47
72
  const content = (
48
73
  <>
49
74
  {presentation !== "inline" && (
50
- <h2 id={titleId} className="text-title3 font-semibold">
75
+ <h2 id={titleId} className={cn(titleText[size], "font-semibold")}>
51
76
  {title}
52
77
  </h2>
53
78
  )}
54
- {description && <div className="text-footnote text-label-secondary">{description}</div>}
79
+ {description && (
80
+ <div className={cn(descriptionText[size], "text-label-secondary")}>
81
+ {description}
82
+ </div>
83
+ )}
55
84
  {children && <div className="flex flex-col gap-4">{children}</div>}
56
- {footer && <div className="flex flex-wrap justify-end gap-2 pt-3">{footer}</div>}
85
+ {footer && (
86
+ <div className={cn("flex flex-wrap justify-end gap-2", footerSpacing[size])}>
87
+ {footer}
88
+ </div>
89
+ )}
57
90
  </>
58
91
  )
59
92
  const body = (
@@ -61,12 +94,22 @@ export function Dialog({
61
94
  {onSubmit ? (
62
95
  <form
63
96
  onSubmit={onSubmit}
64
- className={cn("flex min-h-0 flex-col gap-4 overflow-y-auto p-5", className)}
97
+ className={cn(
98
+ "flex min-h-0 flex-col overflow-y-auto",
99
+ bodySpacing[size],
100
+ className
101
+ )}
65
102
  >
66
103
  {content}
67
104
  </form>
68
105
  ) : (
69
- <div className={cn("flex min-h-0 flex-col gap-4 overflow-y-auto p-5", className)}>
106
+ <div
107
+ className={cn(
108
+ "flex min-h-0 flex-col overflow-y-auto",
109
+ bodySpacing[size],
110
+ className
111
+ )}
112
+ >
70
113
  {content}
71
114
  </div>
72
115
  )}
@@ -4,6 +4,29 @@ import { ChevronDownIcon } from "../../icons/icons"
4
4
  import { cn } from "../../lib/class-names"
5
5
  import { InlineDisclosure } from "../inline-disclosure/inline-disclosure"
6
6
 
7
+ export type DisclosureGroupSize = "sm" | "md" | "lg"
8
+
9
+ const triggerSizes: Record<DisclosureGroupSize, string> = {
10
+ sm: "h-auto min-h-9 w-full rounded-lg px-3 py-2 text-start text-footnote active:scale-100",
11
+ md: "h-auto min-h-11 w-full rounded-xl px-4 py-3 text-start text-subheadline active:scale-100",
12
+ lg: "h-auto min-h-13 w-full rounded-2xl px-5 py-4 text-start text-body active:scale-100",
13
+ }
14
+ const descriptionSizes: Record<DisclosureGroupSize, string> = {
15
+ sm: "text-caption2 leading-caption2 font-normal break-words whitespace-normal text-label-secondary",
16
+ md: "text-footnote leading-footnote font-normal break-words whitespace-normal text-label-secondary",
17
+ lg: "text-subheadline leading-subheadline font-normal break-words whitespace-normal text-label-secondary",
18
+ }
19
+ const contentSizes: Record<DisclosureGroupSize, string> = {
20
+ sm: "min-w-0 px-3 pb-3 text-footnote leading-footnote text-label-secondary [overflow-wrap:anywhere]",
21
+ md: "min-w-0 px-4 pb-4 text-subheadline leading-subheadline text-label-secondary [overflow-wrap:anywhere]",
22
+ lg: "min-w-0 px-5 pb-5 text-body leading-body text-label-secondary [overflow-wrap:anywhere]",
23
+ }
24
+ const chevronSizes: Record<DisclosureGroupSize, number> = {
25
+ sm: 15,
26
+ md: 17,
27
+ lg: 19,
28
+ }
29
+
7
30
  export interface DisclosureGroupProps {
8
31
  label: string
9
32
  open: boolean
@@ -15,6 +38,8 @@ export interface DisclosureGroupProps {
15
38
  triggerClassName?: string
16
39
  /** Supporting text beneath the title. */
17
40
  description?: string
41
+ /** Trigger, description and content density. @default "md" */
42
+ size?: DisclosureGroupSize
18
43
  }
19
44
 
20
45
  /** A controlled, accessible disclosure row with the shared inline reveal. */
@@ -27,6 +52,7 @@ export function DisclosureGroup({
27
52
  className,
28
53
  triggerClassName,
29
54
  description,
55
+ size = "md",
30
56
  }: DisclosureGroupProps) {
31
57
  const id = useId()
32
58
 
@@ -43,8 +69,8 @@ export function DisclosureGroup({
43
69
  disabled={disabled}
44
70
  onClick={() => onOpenChange(!open)}
45
71
  className={cn(
46
- "h-auto min-h-11 w-full rounded-xl px-4 py-3 text-start active:scale-100",
47
- "text-subheadline font-normal text-label hover:bg-fill/60 focus-visible:ring-inset focus-visible:ring-offset-0",
72
+ triggerSizes[size],
73
+ "font-normal text-label hover:bg-fill/60 focus-visible:ring-inset focus-visible:ring-offset-0",
48
74
  "focus-visible:outline-accent-text disabled:cursor-not-allowed disabled:opacity-40",
49
75
  triggerClassName
50
76
  )}
@@ -52,13 +78,11 @@ export function DisclosureGroup({
52
78
  <span className="flex min-w-0 flex-col gap-0.5">
53
79
  <span className="font-medium break-words whitespace-normal">{label}</span>
54
80
  {description && (
55
- <span className="text-footnote leading-footnote font-normal break-words whitespace-normal text-label-secondary">
56
- {description}
57
- </span>
81
+ <span className={descriptionSizes[size]}>{description}</span>
58
82
  )}
59
83
  </span>
60
84
  <ChevronDownIcon
61
- size={17}
85
+ size={chevronSizes[size]}
62
86
  aria-hidden="true"
63
87
  className={cn(
64
88
  "shrink-0 text-label-tertiary transition-transform duration-normal motion-reduce:transition-none",
@@ -69,9 +93,7 @@ export function DisclosureGroup({
69
93
  </h3>
70
94
  <section id={id} aria-labelledby={`${id}-trigger`} aria-hidden={!open} inert={!open}>
71
95
  <InlineDisclosure open={open}>
72
- <div className="min-w-0 px-4 pb-4 text-subheadline leading-subheadline text-label-secondary [overflow-wrap:anywhere]">
73
- {children}
74
- </div>
96
+ <div className={contentSizes[size]}>{children}</div>
75
97
  </InlineDisclosure>
76
98
  </section>
77
99
  </div>
@@ -0,0 +1,2 @@
1
+ export * from "./morph"
2
+ export * from "./use-morph-disclosure"
@@ -0,0 +1,210 @@
1
+ import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion } from "motion/react"
2
+ import { type ReactNode, type RefObject, useLayoutEffect, useRef, useState } from "react"
3
+ import { concealMenuSource } from "../context-menu/conceal-menu-source"
4
+ import { menuSurfaceExit, menuSurfaceReveal, type MenuRect } from "../context-menu/menu-reveal"
5
+ import { useSheetFocus } from "../sheet/use-sheet-focus"
6
+ import { SheetPortal } from "../sheet/sheet-portal"
7
+ import { cn } from "../../lib/class-names"
8
+ import { contentCrossfade, fluidCollapse, fluidOvershoot } from "../../motion/transitions"
9
+
10
+ export interface MorphProps extends Omit<HTMLMotionProps<"div">, "children"> {
11
+ /** Controls whether the overlay is presented. */
12
+ open: boolean
13
+ /** Called on Escape or outside press. */
14
+ onClose: () => void
15
+ /** The element whose rect the overlay grows from and collapses back to. */
16
+ sourceRef: RefObject<HTMLElement | null>
17
+ /** Accessible name (used when there is no labelledBy). */
18
+ label: string
19
+ /** ID of a visible heading inside the overlay. */
20
+ labelledBy?: string
21
+ /** Allow tapping the backdrop to close. Escape still calls onClose. @default true */
22
+ dismissOnOutsidePress?: boolean
23
+ className?: string
24
+ children?: ReactNode
25
+ }
26
+
27
+ /**
28
+ * A modal overlay that morphs its glass material from a source element's
29
+ * rect to its own resting rect, then collapses back — Apple's
30
+ * matched-geometry effect. The source is concealed while the overlay is
31
+ * mounted so the material reads as the source itself springing open.
32
+ *
33
+ * Reuses `SheetPortal` (theme inheritance), `useSheetFocus` (focus trap,
34
+ * Escape, modal environment) and the same `menuSurfaceReveal` FLIP
35
+ * primitive that powers anchored `Popover` and `ContextMenu`.
36
+ */
37
+ export function Morph({ open, ...props }: MorphProps) {
38
+ return <AnimatePresence>{open && <OpenMorph {...props} />}</AnimatePresence>
39
+ }
40
+
41
+ function OpenMorph({
42
+ onClose,
43
+ sourceRef,
44
+ label,
45
+ labelledBy,
46
+ dismissOnOutsidePress = true,
47
+ className,
48
+ children,
49
+ ...props
50
+ }: Omit<MorphProps, "open">) {
51
+ const panel = useRef<HTMLDivElement>(null)
52
+ const reducedMotion = useReducedMotion()
53
+ const [settled, setSettled] = useState(false)
54
+ const [target, setTarget] = useState<{ width: number; height: number; radius: number } | null>(
55
+ null
56
+ )
57
+ const [reveal, setReveal] = useState<ReturnType<typeof menuSurfaceReveal> | null>(null)
58
+ useSheetFocus(panel, onClose)
59
+
60
+ // Measure the panel's resting rect and the source's rect, then compute
61
+ // the reveal (initial material state) from menuSurfaceReveal. Re-measures
62
+ // on resize/scroll so the morph stays aligned if either moves.
63
+ useLayoutEffect(() => {
64
+ const surface = panel.current
65
+ const source = sourceRef.current
66
+ if (!surface) return
67
+ const measure = () => {
68
+ const panelRect = surface.getBoundingClientRect()
69
+ const radius = Number.parseFloat(getComputedStyle(surface).borderTopLeftRadius) || 0
70
+ setTarget((current) =>
71
+ current &&
72
+ current.width === panelRect.width &&
73
+ current.height === panelRect.height &&
74
+ current.radius === radius
75
+ ? current
76
+ : { width: panelRect.width, height: panelRect.height, radius }
77
+ )
78
+ const sourceRect = source?.getBoundingClientRect()
79
+ const sourceArg: MenuRect | undefined =
80
+ sourceRect && sourceRect.width > 0 && sourceRect.height > 0
81
+ ? {
82
+ left: sourceRect.left,
83
+ top: sourceRect.top,
84
+ width: sourceRect.width,
85
+ height: sourceRect.height,
86
+ }
87
+ : undefined
88
+ setReveal(
89
+ menuSurfaceReveal(sourceArg, {
90
+ left: panelRect.left,
91
+ top: panelRect.top,
92
+ width: panelRect.width,
93
+ height: panelRect.height,
94
+ radius,
95
+ })
96
+ )
97
+ }
98
+ measure()
99
+ let frame = 0
100
+ const schedule = () => {
101
+ cancelAnimationFrame(frame)
102
+ frame = requestAnimationFrame(measure)
103
+ }
104
+ const observer = new ResizeObserver(schedule)
105
+ observer.observe(surface)
106
+ if (source) observer.observe(source)
107
+ const viewport = window.visualViewport
108
+ viewport?.addEventListener("resize", schedule)
109
+ viewport?.addEventListener("scroll", schedule)
110
+ window.addEventListener("resize", schedule)
111
+ window.addEventListener("scroll", schedule, true)
112
+ return () => {
113
+ observer.disconnect()
114
+ cancelAnimationFrame(frame)
115
+ viewport?.removeEventListener("resize", schedule)
116
+ viewport?.removeEventListener("scroll", schedule)
117
+ window.removeEventListener("resize", schedule)
118
+ window.removeEventListener("scroll", schedule, true)
119
+ }
120
+ }, [sourceRef])
121
+
122
+ // Conceal the source while the morph is mounted; restore on unmount.
123
+ useLayoutEffect(() => {
124
+ const source = sourceRef.current
125
+ if (!source) return
126
+ const restore = concealMenuSource(source, false)
127
+ return () => {
128
+ restore()
129
+ }
130
+ }, [sourceRef])
131
+
132
+ return (
133
+ <SheetPortal>
134
+ <div className="fixed inset-0 z-[75] flex items-center justify-center p-6">
135
+ <motion.div
136
+ aria-hidden="true"
137
+ className="absolute inset-0 bg-black/30 backdrop-blur-md"
138
+ initial={{ opacity: 0 }}
139
+ animate={{ opacity: 1 }}
140
+ exit={{ opacity: 0 }}
141
+ transition={reducedMotion ? { duration: 0 } : { duration: 0.18 }}
142
+ onClick={dismissOnOutsidePress ? onClose : undefined}
143
+ />
144
+ <motion.div
145
+ ref={panel}
146
+ role="dialog"
147
+ aria-modal="true"
148
+ aria-label={labelledBy ? undefined : label}
149
+ aria-labelledby={labelledBy}
150
+ tabIndex={-1}
151
+ className={cn(
152
+ "relative isolate flex w-full flex-col rounded-5xl outline-none",
153
+ "max-h-[92%] max-w-[720px]",
154
+ settled && "overflow-hidden",
155
+ className
156
+ )}
157
+ {...props}
158
+ >
159
+ {/* The morphing material — mirrors Popover's data-popover-surface
160
+ pattern. Starts at the source's rect (offset relative to the
161
+ panel) and grows to fill the panel, with the fluid overshoot
162
+ bounce. Border radius interpolates from the source's
163
+ "pillified" radius to the panel's authored radius. */}
164
+ <motion.div
165
+ key={reveal ? "surface" : "measure"}
166
+ aria-hidden="true"
167
+ data-morph-surface=""
168
+ onAnimationComplete={() => {
169
+ if (reveal) setSettled(true)
170
+ }}
171
+ className="glass-menu pointer-events-none absolute top-0 left-0 -z-10 rounded-5xl"
172
+ style={{ width: "100%", height: "100%" }}
173
+ initial={reducedMotion || !reveal ? false : reveal}
174
+ animate={
175
+ target
176
+ ? {
177
+ x: 0,
178
+ y: 0,
179
+ width: target.width,
180
+ height: target.height,
181
+ opacity: 1,
182
+ borderRadius: target.radius,
183
+ }
184
+ : undefined
185
+ }
186
+ exit={
187
+ reducedMotion || !reveal
188
+ ? { opacity: 0 }
189
+ : { ...menuSurfaceExit(reveal), transition: fluidCollapse }
190
+ }
191
+ transition={
192
+ reducedMotion
193
+ ? { duration: 0 }
194
+ : { ...fluidOvershoot, opacity: { duration: 0.18 } }
195
+ }
196
+ />
197
+ <motion.div
198
+ className="flex min-h-0 flex-1 flex-col overflow-y-auto"
199
+ initial={reducedMotion ? false : contentCrossfade.initial}
200
+ animate={reducedMotion ? undefined : contentCrossfade.animate}
201
+ exit={reducedMotion ? undefined : contentCrossfade.exit}
202
+ transition={reducedMotion ? { duration: 0 } : contentCrossfade.transition}
203
+ >
204
+ {children}
205
+ </motion.div>
206
+ </motion.div>
207
+ </div>
208
+ </SheetPortal>
209
+ )
210
+ }
@@ -0,0 +1,27 @@
1
+ import { useCallback, useEffect, useState } from "react"
2
+
3
+ /**
4
+ * Disclosure state that keeps the overlay mounted through its exit animation,
5
+ * mirroring `useAnchoredDisclosure`. Pair with a `useRef<HTMLElement>(null)`
6
+ * for the source.
7
+ */
8
+ export function useMorphDisclosure(disabled = false) {
9
+ const [open, setOpen] = useState(false)
10
+ const [mounted, setMounted] = useState(false)
11
+ useEffect(() => {
12
+ if (disabled) setOpen(false)
13
+ }, [disabled])
14
+ const show = useCallback(() => {
15
+ setMounted(true)
16
+ setOpen(true)
17
+ }, [])
18
+ const toggle = useCallback(() => {
19
+ setMounted(true)
20
+ setOpen((value) => !value)
21
+ }, [])
22
+ const close = useCallback(() => setOpen(false), [])
23
+ const onExited = useCallback(() => {
24
+ if (!open || disabled) setMounted(false)
25
+ }, [open, disabled])
26
+ return { open: open && !disabled, mounted, show, toggle, close, onExited }
27
+ }
@@ -1,6 +1,29 @@
1
1
  import { useId } from "react"
2
2
  import { cn } from "../../lib/class-names"
3
3
 
4
+ export type RadioGroupSize = "sm" | "md" | "lg"
5
+
6
+ const legendSizes: Record<RadioGroupSize, string> = {
7
+ sm: "mb-1.5 text-caption2 font-medium text-label-secondary",
8
+ md: "mb-2 text-footnote font-medium text-label-secondary",
9
+ lg: "mb-2.5 text-subheadline font-medium text-label-secondary",
10
+ }
11
+ const rowSizes: Record<RadioGroupSize, string> = {
12
+ sm: "flex min-h-7 items-start gap-1.5 text-footnote text-label has-disabled:opacity-40 [@media(pointer:coarse)]:min-h-10",
13
+ md: "flex min-h-8 items-start gap-2 text-subheadline text-label has-disabled:opacity-40 [@media(pointer:coarse)]:min-h-11",
14
+ lg: "flex min-h-10 items-start gap-2.5 text-body text-label has-disabled:opacity-40 [@media(pointer:coarse)]:min-h-12",
15
+ }
16
+ const radioSizes: Record<RadioGroupSize, string> = {
17
+ sm: "mt-0.5 size-3.5 shrink-0 accent-accent-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text",
18
+ md: "mt-1 size-4 shrink-0 accent-accent-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text",
19
+ lg: "mt-1 size-5 shrink-0 accent-accent-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text",
20
+ }
21
+ const descriptionSizes: Record<RadioGroupSize, string> = {
22
+ sm: "block text-caption2 text-label-secondary",
23
+ md: "block text-footnote text-label-secondary",
24
+ lg: "block text-subheadline text-label-secondary",
25
+ }
26
+
4
27
  export interface RadioGroupOption<T extends string> {
5
28
  value: T
6
29
  label: string
@@ -15,6 +38,8 @@ export interface RadioGroupProps<T extends string> {
15
38
  disabled?: boolean
16
39
  name?: string
17
40
  className?: string
41
+ /** Legend, row, radio glyph and description density. @default "md" */
42
+ size?: RadioGroupSize
18
43
  }
19
44
  /** A labelled choice group using the browser's native radio keyboard behavior. */
20
45
  export function RadioGroup<T extends string>({
@@ -25,16 +50,14 @@ export function RadioGroup<T extends string>({
25
50
  disabled,
26
51
  name,
27
52
  className,
53
+ size = "md",
28
54
  }: RadioGroupProps<T>) {
29
55
  const id = useId()
30
56
  return (
31
57
  <fieldset disabled={disabled} className={cn("flex min-w-0 flex-col gap-2", className)}>
32
- <legend className="mb-2 text-footnote font-medium text-label-secondary">{label}</legend>
58
+ <legend className={legendSizes[size]}>{label}</legend>
33
59
  {options.map((option, index) => (
34
- <label
35
- key={option.value}
36
- className="flex min-h-8 items-start gap-2 text-subheadline text-label has-disabled:opacity-40 [@media(pointer:coarse)]:min-h-11"
37
- >
60
+ <label key={option.value} className={rowSizes[size]}>
38
61
  <input
39
62
  type="radio"
40
63
  name={name ?? id}
@@ -43,15 +66,12 @@ export function RadioGroup<T extends string>({
43
66
  disabled={option.disabled}
44
67
  onChange={() => onChange(option.value)}
45
68
  aria-describedby={option.description ? `${id}-${index}` : undefined}
46
- className="mt-1 size-4 shrink-0 accent-accent-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text"
69
+ className={radioSizes[size]}
47
70
  />
48
71
  <span>
49
72
  {option.label}
50
73
  {option.description && (
51
- <span
52
- id={`${id}-${index}`}
53
- className="block text-footnote text-label-secondary"
54
- >
74
+ <span id={`${id}-${index}`} className={descriptionSizes[size]}>
55
75
  {option.description}
56
76
  </span>
57
77
  )}
@@ -7,6 +7,18 @@ import { SheetPortal } from "./sheet-portal"
7
7
  import { useSheetFocus } from "./use-sheet-focus"
8
8
 
9
9
  export type SheetPlacement = "adaptive" | "center"
10
+ export type SheetSize = "sm" | "md" | "lg"
11
+
12
+ const outerPaddings: Record<SheetSize, string> = {
13
+ sm: "p-4",
14
+ md: "p-6",
15
+ lg: "p-8",
16
+ }
17
+ const panelMaxWidths: Record<SheetSize, string> = {
18
+ sm: "max-w-sm",
19
+ md: "max-w-[560px]",
20
+ lg: "max-w-2xl",
21
+ }
10
22
 
11
23
  export interface SheetProps {
12
24
  /** Controls whether the sheet is presented. */
@@ -21,6 +33,8 @@ export interface SheetProps {
21
33
  labelledBy?: string
22
34
  /** `adaptive` rises from the bottom on phones; `center` always floats. @default "adaptive" */
23
35
  placement?: SheetPlacement
36
+ /** Outer padding and panel max-width. @default "md" */
37
+ size?: SheetSize
24
38
  className?: string
25
39
  children: ReactNode
26
40
  }
@@ -47,6 +61,7 @@ function OpenSheet({
47
61
  label,
48
62
  labelledBy,
49
63
  placement = "adaptive",
64
+ size = "md",
50
65
  className,
51
66
  children,
52
67
  }: Omit<SheetProps, "open">) {
@@ -59,7 +74,9 @@ function OpenSheet({
59
74
  <div
60
75
  className={cn(
61
76
  "fixed inset-0 z-[65] flex justify-center",
62
- placement === "center" ? "items-center p-6" : "items-end sm:items-center sm:p-6"
77
+ placement === "center"
78
+ ? cn("items-center", outerPaddings[size])
79
+ : cn("items-end sm:items-center", "sm:" + outerPaddings[size])
63
80
  )}
64
81
  style={
65
82
  viewport
@@ -91,7 +108,8 @@ function OpenSheet({
91
108
  tabIndex={-1}
92
109
  className={cn(
93
110
  "glass-menu relative flex w-full flex-col overflow-hidden",
94
- "max-h-[92%] max-w-[560px]",
111
+ "max-h-[92%]",
112
+ panelMaxWidths[size],
95
113
  placement === "center" ? "rounded-sheet" : "rounded-t-sheet sm:rounded-sheet",
96
114
  className
97
115
  )}
@@ -14,6 +14,34 @@ export interface SwatchPickerOption<T extends string> {
14
14
  ink: string
15
15
  }
16
16
 
17
+ export type SwatchPickerSize = "sm" | "md" | "lg"
18
+
19
+ const swatchSizes: Record<SwatchPickerSize, string> = {
20
+ sm: "size-6",
21
+ md: "size-8",
22
+ lg: "size-10",
23
+ }
24
+ const checkSizes: Record<SwatchPickerSize, number> = {
25
+ sm: 13,
26
+ md: 16,
27
+ lg: 19,
28
+ }
29
+ const captionText: Record<SwatchPickerSize, string> = {
30
+ sm: "text-caption2",
31
+ md: "text-caption2",
32
+ lg: "text-caption",
33
+ }
34
+ const fieldsetGaps: Record<SwatchPickerSize, string> = {
35
+ sm: "gap-2",
36
+ md: "gap-3",
37
+ lg: "gap-4",
38
+ }
39
+ const labelGaps: Record<SwatchPickerSize, string> = {
40
+ sm: "gap-1",
41
+ md: "gap-1.5",
42
+ lg: "gap-2",
43
+ }
44
+
17
45
  export interface SwatchPickerProps<T extends string> {
18
46
  value: T
19
47
  options: ReadonlyArray<SwatchPickerOption<T>>
@@ -22,6 +50,8 @@ export interface SwatchPickerProps<T extends string> {
22
50
  label: string
23
51
  /** Custom classes for the swatch container. */
24
52
  className?: string
53
+ /** Swatch diameter, check icon and caption scale. @default "md" */
54
+ size?: SwatchPickerSize
25
55
  }
26
56
 
27
57
  /** Colour swatches with a check on the chosen one. */
@@ -31,17 +61,21 @@ export function SwatchPicker<T extends string>({
31
61
  onChange,
32
62
  label,
33
63
  className,
64
+ size = "md",
34
65
  }: SwatchPickerProps<T>) {
35
66
  const name = useId()
36
67
  return (
37
- <fieldset className={cn("flex flex-wrap gap-3", className)}>
68
+ <fieldset className={cn("flex flex-wrap", fieldsetGaps[size], className)}>
38
69
  <legend className="sr-only">{label}</legend>
39
70
  {options.map((option) => {
40
71
  const selected = option.value === value
41
72
  return (
42
73
  <label
43
74
  key={option.value}
44
- className="group flex cursor-pointer flex-col items-center gap-1.5"
75
+ className={cn(
76
+ "group flex cursor-pointer flex-col items-center",
77
+ labelGaps[size]
78
+ )}
45
79
  >
46
80
  <input
47
81
  type="radio"
@@ -56,7 +90,8 @@ export function SwatchPicker<T extends string>({
56
90
  />
57
91
  <span
58
92
  className={cn(
59
- "flex size-8 items-center justify-center rounded-full ring-offset-1 ring-offset-elevated transition-[box-shadow,transform] duration-fast group-active:scale-95 peer-focus-visible:ring-2 peer-focus-visible:ring-accent",
93
+ "flex items-center justify-center rounded-full ring-offset-1 ring-offset-elevated transition-[box-shadow,transform] duration-fast group-active:scale-95 peer-focus-visible:ring-2 peer-focus-visible:ring-accent",
94
+ swatchSizes[size],
60
95
  selected
61
96
  ? "ring-[1.5px] ring-label/70"
62
97
  : "ring-0 group-hover:ring-[1.5px] group-hover:ring-separator"
@@ -78,7 +113,7 @@ export function SwatchPicker<T extends string>({
78
113
  transition={spring.bouncy}
79
114
  >
80
115
  <CheckIcon
81
- size={16}
116
+ size={checkSizes[size]}
82
117
  strokeWidth={2.6}
83
118
  style={{
84
119
  color: option.ink,
@@ -89,7 +124,7 @@ export function SwatchPicker<T extends string>({
89
124
  </span>
90
125
  <span
91
126
  className={cn(
92
- "text-caption2",
127
+ captionText[size],
93
128
  selected ? "font-semibold text-label" : "text-label-tertiary"
94
129
  )}
95
130
  >
package/src/index.ts CHANGED
@@ -102,3 +102,5 @@ export * from "./components/wake-slider/wake-slider"
102
102
  export * from "./components/one-time-code/one-time-code"
103
103
 
104
104
  export * from "./components/aspect-ratio/aspect-ratio"
105
+
106
+ export * from "./components/morph"