aeon-ui-engine 1.0.0 → 1.0.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 (79) hide show
  1. package/COMPONENTS.md +7 -2
  2. package/INTEGRATION.md +14 -0
  3. package/README.md +8 -4
  4. package/apps/demo/og.config.mjs +8 -3
  5. package/apps/demo/public/og-aeon-ui-engine.png +0 -0
  6. package/apps/demo/public/og.svg +2 -2
  7. package/apps/demo/scripts/generate-og-png.mjs +4 -2
  8. package/apps/demo/src/App.tsx +2 -2
  9. package/apps/demo/src/aeonDiagrams.ts +17 -75
  10. package/apps/demo/src/components/DiagramsSection.tsx +8 -9
  11. package/apps/demo/src/components/HeroInstall.tsx +7 -4
  12. package/apps/demo/src/components/LandingFaq.tsx +1 -1
  13. package/apps/demo/src/components/LandingFooter.tsx +8 -0
  14. package/apps/demo/src/components/LandingHeader.tsx +4 -2
  15. package/apps/demo/src/components/LayersSection.tsx +6 -10
  16. package/apps/demo/src/components/MermaidDiagram.tsx +5 -3
  17. package/apps/demo/src/components/PhilosophySection.tsx +0 -2
  18. package/apps/demo/src/components/PlaygroundBlock.tsx +3 -8
  19. package/apps/demo/src/components/PlaygroundControlsDemo.tsx +63 -0
  20. package/apps/demo/src/components/PlaygroundSection.tsx +7 -91
  21. package/apps/demo/src/components/ThemeSwitcher.tsx +12 -9
  22. package/apps/demo/src/demo-layout.css +29 -0
  23. package/apps/demo/src/landing.css +136 -79
  24. package/apps/demo/src/nav.ts +3 -3
  25. package/apps/demo/src/site.ts +99 -228
  26. package/docs/MACHINES.md +6 -1
  27. package/package.json +1 -1
  28. package/packages/core/src/anatomy.ts +23 -0
  29. package/packages/panda/panda.config.ts +19 -1
  30. package/packages/panda/src/recipes/index.ts +5 -0
  31. package/packages/panda/src/recipes/pin-input.ts +29 -0
  32. package/packages/panda/src/recipes/progress.ts +44 -0
  33. package/packages/panda/src/recipes/radio-group.ts +51 -0
  34. package/packages/panda/src/recipes/select.ts +2 -0
  35. package/packages/panda/src/recipes/separator.ts +35 -0
  36. package/packages/panda/src/recipes/slider.ts +57 -0
  37. package/packages/panda/styled-system/recipes/aeon-pin-input.d.ts +28 -0
  38. package/packages/panda/styled-system/recipes/aeon-pin-input.mjs +36 -0
  39. package/packages/panda/styled-system/recipes/aeon-progress.d.ts +28 -0
  40. package/packages/panda/styled-system/recipes/aeon-progress.mjs +44 -0
  41. package/packages/panda/styled-system/recipes/aeon-radio-group.d.ts +28 -0
  42. package/packages/panda/styled-system/recipes/aeon-radio-group.mjs +48 -0
  43. package/packages/panda/styled-system/recipes/aeon-separator.d.ts +31 -0
  44. package/packages/panda/styled-system/recipes/aeon-separator.mjs +41 -0
  45. package/packages/panda/styled-system/recipes/aeon-slider.d.ts +28 -0
  46. package/packages/panda/styled-system/recipes/aeon-slider.mjs +48 -0
  47. package/packages/panda/styled-system/recipes/index.d.ts +5 -0
  48. package/packages/panda/styled-system/recipes/index.mjs +5 -0
  49. package/packages/panda/styled-system/styles.css +223 -46
  50. package/packages/panda/styled-system/types/conditions.d.ts +1 -1
  51. package/packages/primitives/src/index.ts +1 -0
  52. package/packages/primitives/src/machines/slider.ts +93 -0
  53. package/packages/react/src/checkbox/index.tsx +14 -0
  54. package/packages/react/src/floating/FloatingPositioner.tsx +59 -0
  55. package/packages/react/src/hooks/use-anchor-position.ts +33 -5
  56. package/packages/react/src/hooks/use-prefers-hover.ts +16 -0
  57. package/packages/react/src/index.ts +5 -0
  58. package/packages/react/src/menu/index.tsx +38 -11
  59. package/packages/react/src/pin-input/index.tsx +223 -0
  60. package/packages/react/src/popover/index.tsx +38 -11
  61. package/packages/react/src/progress/index.tsx +110 -0
  62. package/packages/react/src/radio-group/index.tsx +199 -0
  63. package/packages/react/src/select/index.tsx +51 -28
  64. package/packages/react/src/select/select.test.tsx +21 -0
  65. package/packages/react/src/separator/index.tsx +23 -0
  66. package/packages/react/src/slider/index.tsx +264 -0
  67. package/packages/react/src/toast/primitives.tsx +8 -1
  68. package/packages/react/src/toast/toast.test.tsx +40 -0
  69. package/packages/react/src/tooltip/index.tsx +124 -20
  70. package/packages/react/src/tooltip/tooltip.test.tsx +114 -0
  71. package/packages/site/README.md +1 -1
  72. package/packages/ui/src/index.ts +5 -0
  73. package/packages/ui/src/pin-input.tsx +16 -0
  74. package/packages/ui/src/progress.tsx +21 -0
  75. package/packages/ui/src/radio-group.tsx +24 -0
  76. package/packages/ui/src/separator.tsx +15 -0
  77. package/packages/ui/src/slider.tsx +24 -0
  78. package/apps/demo/public/og.png +0 -0
  79. package/apps/demo/src/components/SceneSection.tsx +0 -157
@@ -336,6 +336,21 @@
336
336
  pointer-events: none;
337
337
  }
338
338
 
339
+ @keyframes aeonProgressIndeterminate {
340
+ 0% {
341
+ transform: translateX(-100%);
342
+ }
343
+
344
+ 100% {
345
+ transform: translateX(250%);
346
+ }
347
+ }
348
+
349
+ [data-aeon-scope="radioGroup"] [data-aeon-part="item"] {
350
+ display: inline-flex;
351
+ align-items: center;
352
+ }
353
+
339
354
  *,::before,::after,::backdrop {
340
355
  --blur: /*-*/ /*-*/;
341
356
  --brightness: /*-*/ /*-*/;
@@ -1239,7 +1254,7 @@
1239
1254
  transition-duration: var(--durations-normal);
1240
1255
  }
1241
1256
 
1242
- .aeonCheckbox__control[data-aeon-state*=checked] {
1257
+ .aeonCheckbox__control[data-aeon-state~="checked"] {
1243
1258
  background: var(--colors-accent);
1244
1259
  border-color: var(--colors-accent);
1245
1260
  }
@@ -1640,6 +1655,36 @@
1640
1655
  background: var(--colors-border);
1641
1656
  }
1642
1657
 
1658
+ .aeonPinInput__root {
1659
+ display: inline-flex;
1660
+ gap: var(--spacing-gap-sm);
1661
+ }
1662
+
1663
+ .aeonPinInput__input {
1664
+ width: 2.5rem;
1665
+ height: 2.75rem;
1666
+ text-align: center;
1667
+ border-radius: var(--radii-md);
1668
+ background: var(--colors-surface);
1669
+ color: var(--colors-fg);
1670
+ outline: var(--borders-none);
1671
+ font-size: var(--font-sizes-lg);
1672
+ font-family: var(--fonts-ui);
1673
+ font-weight: var(--font-weights-semibold);
1674
+ border-width: 1px;
1675
+ border-style: solid;
1676
+ border-color: var(--colors-border);
1677
+ }
1678
+
1679
+ .aeonPinInput__input[data-aeon-state=filled] {
1680
+ border-color: var(--colors-accent);
1681
+ }
1682
+
1683
+ .aeonPinInput__input:is(:focus-visible, [data-focus-visible]) {
1684
+ box-shadow: var(--shadows-focus-ring);
1685
+ border-color: var(--colors-accent);
1686
+ }
1687
+
1643
1688
  .aeonPopover__root {
1644
1689
  position: relative;
1645
1690
  display: inline-block;
@@ -1701,6 +1746,108 @@
1701
1746
  color: var(--colors-fg);
1702
1747
  }
1703
1748
 
1749
+ .aeonProgress__root {
1750
+ display: flex;
1751
+ gap: var(--spacing-gap-sm);
1752
+ width: 100%;
1753
+ flex-direction: column;
1754
+ font-family: var(--fonts-ui);
1755
+ }
1756
+
1757
+ .aeonProgress__root[data-aeon-state=loading] .aeonProgress__range {
1758
+ width: 40% !important;
1759
+ animation: aeonProgressIndeterminate 1.2s ease-in-out infinite;
1760
+ }
1761
+
1762
+ .aeonProgress__track {
1763
+ position: relative;
1764
+ width: 100%;
1765
+ height: 0.5rem;
1766
+ border-radius: var(--radii-full);
1767
+ background: var(--colors-surface);
1768
+ overflow: hidden;
1769
+ border-width: 1px;
1770
+ border-style: solid;
1771
+ border-color: var(--colors-border);
1772
+ }
1773
+
1774
+ .aeonProgress__range {
1775
+ height: 100%;
1776
+ border-radius: var(--radii-full);
1777
+ background: var(--colors-accent);
1778
+ --transition-prop: width;
1779
+ transition-property: width;
1780
+ --transition-duration: var(--durations-normal);
1781
+ transition-duration: var(--durations-normal);
1782
+ }
1783
+
1784
+ .aeonProgress__label {
1785
+ color: var(--colors-muted);
1786
+ font-size: var(--font-sizes-sm);
1787
+ line-height: var(--line-heights-snug);
1788
+ }
1789
+
1790
+ .aeonRadioGroup__root {
1791
+ display: flex;
1792
+ gap: var(--spacing-gap-sm);
1793
+ flex-direction: column;
1794
+ font-family: var(--fonts-ui);
1795
+ }
1796
+
1797
+ .aeonRadioGroup__item {
1798
+ gap: var(--spacing-gap-lg);
1799
+ vertical-align: middle;
1800
+ font-family: var(--fonts-ui);
1801
+ line-height: 1.5;
1802
+ }
1803
+
1804
+ .aeonRadioGroup__item,.aeonRadioGroup__itemControl {
1805
+ display: inline-flex;
1806
+ cursor: pointer;
1807
+ align-items: center;
1808
+ }
1809
+
1810
+ .aeonRadioGroup__itemControl {
1811
+ width: 1.5rem;
1812
+ height: 1.5rem;
1813
+ border-radius: var(--radii-full);
1814
+ background: var(--colors-surface);
1815
+ outline: var(--borders-none);
1816
+ justify-content: center;
1817
+ flex-shrink: 0;
1818
+ border-width: 1px;
1819
+ border-style: solid;
1820
+ border-color: var(--colors-border);
1821
+ --transition-prop: background-color, border-color;
1822
+ transition-property: background-color, border-color;
1823
+ --transition-duration: var(--durations-normal);
1824
+ transition-duration: var(--durations-normal);
1825
+ }
1826
+
1827
+ .aeonRadioGroup__itemControl[data-aeon-state=selected] {
1828
+ border-color: var(--colors-accent);
1829
+ }
1830
+
1831
+ .aeonRadioGroup__itemControl:is(:focus-visible, [data-focus-visible]) {
1832
+ box-shadow: var(--shadows-focus-ring);
1833
+ }
1834
+
1835
+ .aeonRadioGroup__itemIndicator {
1836
+ width: 0.625rem;
1837
+ height: 0.625rem;
1838
+ border-radius: var(--radii-full);
1839
+ background: var(--colors-accent);
1840
+ }
1841
+
1842
+ .aeonRadioGroup__itemLabel {
1843
+ color: var(--colors-fg);
1844
+ -webkit-user-select: none;
1845
+ user-select: none;
1846
+ cursor: pointer;
1847
+ font-size: var(--font-sizes-sm);
1848
+ line-height: 1.5;
1849
+ }
1850
+
1704
1851
  .aeonScroll__root {
1705
1852
  display: flex;
1706
1853
  gap: var(--spacing-gap-sm);
@@ -1733,6 +1880,7 @@
1733
1880
  display: inline-flex;
1734
1881
  gap: var(--spacing-gap-sm);
1735
1882
  width: 100%;
1883
+ box-sizing: border-box;
1736
1884
  min-height: 2rem;
1737
1885
  padding-block: var(--spacing-control-y);
1738
1886
  padding-inline: var(--spacing-control-x);
@@ -1787,6 +1935,7 @@
1787
1935
  border-right: 2px solid;
1788
1936
  border-bottom: 2px solid;
1789
1937
  transform: rotate(45deg) translateY(-1px);
1938
+ transform-origin: center;
1790
1939
  flex-shrink: 0;
1791
1940
  border-color: var(--colors-muted);
1792
1941
  --transition-prop: transform;
@@ -1843,6 +1992,73 @@
1843
1992
  color: var(--colors-accent);
1844
1993
  }
1845
1994
 
1995
+ .aeonSeparator__root {
1996
+ background: var(--colors-border);
1997
+ border: var(--borders-none);
1998
+ flex-shrink: 0;
1999
+ }
2000
+
2001
+ .aeonSlider__root {
2002
+ position: relative;
2003
+ display: flex;
2004
+ gap: var(--spacing-gap-sm);
2005
+ width: 100%;
2006
+ min-width: 8rem;
2007
+ flex-direction: column;
2008
+ }
2009
+
2010
+ .aeonSlider__track {
2011
+ position: relative;
2012
+ height: 0.35rem;
2013
+ border-radius: var(--radii-full);
2014
+ background: var(--colors-surface);
2015
+ cursor: pointer;
2016
+ touch-action: none;
2017
+ border-width: 1px;
2018
+ border-style: solid;
2019
+ border-color: var(--colors-border);
2020
+ }
2021
+
2022
+ .aeonSlider__range {
2023
+ height: 100%;
2024
+ background: var(--colors-accent-dim);
2025
+ pointer-events: none;
2026
+ top: var(--spacing-0);
2027
+ left: var(--spacing-0);
2028
+ }
2029
+
2030
+ .aeonSlider__range,.aeonSlider__thumb {
2031
+ position: absolute;
2032
+ border-radius: var(--radii-full);
2033
+ }
2034
+
2035
+ .aeonSlider__thumb {
2036
+ width: 1rem;
2037
+ height: 1rem;
2038
+ background: var(--colors-accent);
2039
+ transform: translate(-50%, -50%);
2040
+ cursor: grab;
2041
+ box-shadow: var(--shadows-panel);
2042
+ top: 50%;
2043
+ border-width: 2px;
2044
+ border-style: solid;
2045
+ border-color: var(--colors-surface-raised);
2046
+ }
2047
+
2048
+ .aeonSlider__thumb[data-aeon-state=dragging] {
2049
+ cursor: grabbing;
2050
+ }
2051
+
2052
+ .aeonSlider__thumb:is(:focus-visible, [data-focus-visible]) {
2053
+ box-shadow: var(--shadows-focus-ring);
2054
+ }
2055
+
2056
+ .aeonSlider__valueText {
2057
+ color: var(--colors-muted);
2058
+ font-size: var(--font-sizes-xs);
2059
+ font-family: var(--fonts-ui);
2060
+ }
2061
+
1846
2062
  .aeonSwitch__root {
1847
2063
  gap: var(--spacing-gap-lg);
1848
2064
  font-family: var(--fonts-ui);
@@ -1880,7 +2096,7 @@
1880
2096
  transition-duration: var(--durations-normal);
1881
2097
  }
1882
2098
 
1883
- .aeonSwitch__control[data-aeon-state*=checked] {
2099
+ .aeonSwitch__control[data-aeon-state~="checked"] {
1884
2100
  background: var(--colors-accent-dim);
1885
2101
  border-color: var(--colors-accent);
1886
2102
  }
@@ -1902,7 +2118,7 @@
1902
2118
  transition-duration: var(--durations-normal);
1903
2119
  }
1904
2120
 
1905
- .aeonSwitch__thumb[data-aeon-state*=checked] {
2121
+ .aeonSwitch__thumb[data-aeon-state~="checked"] {
1906
2122
  background: var(--colors-accent);
1907
2123
  margin-left: auto;
1908
2124
  }
@@ -2091,53 +2307,14 @@
2091
2307
  .aeonScroll__viewport--maxW_full {
2092
2308
  max-width: 100%;
2093
2309
  }
2094
- }
2095
-
2096
- @layer utilities{
2097
-
2098
- .min-h_340 {
2099
- min-height: 340px;
2100
- }
2101
-
2102
- .distance_980 {
2103
- distance: 980px;
2104
- }
2105
-
2106
- .origin_50\%_36\% {
2107
- origin: 50% 36%;
2108
- }
2109
2310
 
2110
- .translate-z_-130 {
2111
- --translate-z: -130;
2112
- }
2113
-
2114
- .translate-z_-85 {
2115
- --translate-z: -85;
2116
- }
2117
-
2118
- .translate-z_-55 {
2119
- --translate-z: -55;
2120
- }
2121
-
2122
- .rotate-y_42 {
2123
- --rotate-y: 42;
2124
- }
2125
-
2126
- .rotate-y_-42 {
2127
- --rotate-y: -42;
2128
- }
2129
-
2130
- .translate-z_0 {
2131
- --translate-z: var(--spacing-0);
2311
+ .aeonSeparator__root--orientation_horizontal {
2312
+ width: 100%;
2313
+ height: 1px;
2132
2314
  }
2133
-
2134
- .rotate-x_56 {
2135
- --rotate-x: 56;
2136
2315
  }
2137
2316
 
2138
- .translate-z_28 {
2139
- --translate-z: var(--spacing-28);
2140
- }
2317
+ @layer utilities{
2141
2318
 
2142
2319
  .max-h_sm {
2143
2320
  max-height: var(--sizes-sm);
@@ -214,7 +214,7 @@ export interface Conditions {
214
214
  "_starting": string
215
215
  /** `&[data-aeon-state=selected]` */
216
216
  "_aeonSelected": string
217
- /** `&[data-aeon-state*=checked]` */
217
+ /** `&[data-aeon-state~="checked"]` */
218
218
  "_aeonChecked": string
219
219
  /** `&[data-aeon-state=open]` */
220
220
  "_aeonOpen": string
@@ -7,3 +7,4 @@ export * from './machines/button.js'
7
7
  export * from './machines/field.js'
8
8
  export * from './machines/popover.js'
9
9
  export * from './machines/toast.js'
10
+ export * from './machines/slider.js'
@@ -0,0 +1,93 @@
1
+ import { assign, setup } from 'xstate'
2
+
3
+ export interface SliderContext {
4
+ value: number
5
+ min: number
6
+ max: number
7
+ step: number
8
+ disabled: boolean
9
+ }
10
+
11
+ export type SliderEvent =
12
+ | { type: 'SET_VALUE'; value: number }
13
+ | { type: 'STEP'; delta: number }
14
+ | { type: 'POINTER_DOWN' }
15
+ | { type: 'POINTER_UP' }
16
+ | { type: 'HOME' }
17
+ | { type: 'END' }
18
+
19
+ function clamp(value: number, min: number, max: number) {
20
+ return Math.min(max, Math.max(min, value))
21
+ }
22
+
23
+ function snap(value: number, min: number, max: number, step: number) {
24
+ if (step <= 0) return clamp(value, min, max)
25
+ const snapped = min + Math.round((value - min) / step) * step
26
+ return clamp(snapped, min, max)
27
+ }
28
+
29
+ /** Range control — idle vs dragging interaction regions. */
30
+ export const sliderMachine = setup({
31
+ types: {
32
+ context: {} as SliderContext,
33
+ events: {} as SliderEvent,
34
+ input: {} as SliderContext,
35
+ },
36
+ guards: {
37
+ notDisabled: ({ context }) => !context.disabled,
38
+ },
39
+ actions: {
40
+ setValue: assign({
41
+ value: ({ context, event }) =>
42
+ event.type === 'SET_VALUE'
43
+ ? snap(event.value, context.min, context.max, context.step)
44
+ : context.value,
45
+ }),
46
+ step: assign({
47
+ value: ({ context, event }) =>
48
+ event.type === 'STEP'
49
+ ? snap(context.value + event.delta, context.min, context.max, context.step)
50
+ : context.value,
51
+ }),
52
+ home: assign({
53
+ value: ({ context }) => context.min,
54
+ }),
55
+ end: assign({
56
+ value: ({ context }) => context.max,
57
+ }),
58
+ },
59
+ }).createMachine({
60
+ id: 'slider',
61
+ context: ({ input }) => ({
62
+ value: snap(input.value ?? input.min, input.min, input.max, input.step),
63
+ min: input.min,
64
+ max: input.max,
65
+ step: input.step,
66
+ disabled: input.disabled ?? false,
67
+ }),
68
+ initial: 'interaction',
69
+ states: {
70
+ interaction: {
71
+ initial: 'idle',
72
+ states: {
73
+ idle: {
74
+ on: {
75
+ SET_VALUE: { actions: 'setValue', guard: 'notDisabled' },
76
+ STEP: { actions: 'step', guard: 'notDisabled' },
77
+ HOME: { actions: 'home', guard: 'notDisabled' },
78
+ END: { actions: 'end', guard: 'notDisabled' },
79
+ POINTER_DOWN: { target: 'dragging', guard: 'notDisabled' },
80
+ },
81
+ },
82
+ dragging: {
83
+ on: {
84
+ SET_VALUE: { actions: 'setValue' },
85
+ POINTER_UP: 'idle',
86
+ },
87
+ },
88
+ },
89
+ },
90
+ },
91
+ })
92
+
93
+ export { clamp as clampSliderValue, snap as snapSliderValue }
@@ -69,6 +69,13 @@ const Root = forwardRef<HTMLLabelElement, CheckboxRootProps>(function CheckboxRo
69
69
  [resolvedChecked, disabled, checked, onCheckedChange, send],
70
70
  )
71
71
 
72
+ const toggle = () => {
73
+ if (disabled) return
74
+ const next = !resolvedChecked
75
+ if (checked !== undefined) onCheckedChange?.(next)
76
+ else send({ type: 'TOGGLE' })
77
+ }
78
+
72
79
  return (
73
80
  <CheckboxCtx.Provider value={value}>
74
81
  <label
@@ -79,6 +86,13 @@ const Root = forwardRef<HTMLLabelElement, CheckboxRootProps>(function CheckboxRo
79
86
  state: resolvedChecked ? 'checked' : 'unchecked',
80
87
  disabled,
81
88
  }),
89
+ {
90
+ onClick: (e: MouseEvent<HTMLLabelElement>) => {
91
+ const target = e.target as HTMLElement
92
+ if (target.closest(`[data-aeon-part="${checkboxAnatomy.control}"]`)) return
93
+ toggle()
94
+ },
95
+ },
82
96
  rest as ButtonHTMLAttributes<HTMLLabelElement>,
83
97
  )}
84
98
  >
@@ -0,0 +1,59 @@
1
+ import { partAttrs } from '@aeon-ui/core'
2
+ import { forwardRef, type HTMLAttributes, type ReactNode, type RefObject } from 'react'
3
+ import { useAnchorPosition, type AnchorPlacement } from '../hooks/use-anchor-position.js'
4
+ import { renderPortalled, usePortalledMount } from '../hooks/use-portalled.js'
5
+ import { mergeProps } from '../utils/merge-props.js'
6
+
7
+ export interface FloatingPositionerProps extends HTMLAttributes<HTMLDivElement> {
8
+ open: boolean
9
+ scope: string
10
+ part: string
11
+ triggerRef: RefObject<HTMLElement | null>
12
+ positionerRef: RefObject<HTMLDivElement | null>
13
+ portalled?: boolean
14
+ placement?: AnchorPlacement
15
+ matchAnchorWidth?: boolean
16
+ children?: ReactNode
17
+ }
18
+
19
+ export const FloatingPositioner = forwardRef<HTMLDivElement, FloatingPositionerProps>(
20
+ function FloatingPositioner(
21
+ {
22
+ open,
23
+ scope,
24
+ part,
25
+ triggerRef,
26
+ positionerRef,
27
+ portalled = true,
28
+ placement = 'bottom-start',
29
+ matchAnchorWidth = false,
30
+ children,
31
+ className,
32
+ ...rest
33
+ },
34
+ ref,
35
+ ) {
36
+ const mounted = usePortalledMount()
37
+ useAnchorPosition(triggerRef, positionerRef, open && portalled, placement, {
38
+ matchWidth: matchAnchorWidth,
39
+ })
40
+
41
+ if (!open) return null
42
+
43
+ const node = (
44
+ <div
45
+ ref={(el) => {
46
+ positionerRef.current = el
47
+ if (typeof ref === 'function') ref(el)
48
+ else if (ref) ref.current = el
49
+ }}
50
+ className={className}
51
+ {...mergeProps(partAttrs(scope, part, { state: 'open' }), rest)}
52
+ >
53
+ {children}
54
+ </div>
55
+ )
56
+
57
+ return renderPortalled(node, portalled, mounted)
58
+ },
59
+ )
@@ -1,11 +1,22 @@
1
1
  import { useEffect, type RefObject } from 'react'
2
2
 
3
- /** Pin floating listbox to trigger rect (for portalled selects). */
3
+ export type AnchorPlacement = 'bottom-start' | 'top-start'
4
+
5
+ export interface AnchorPositionOptions {
6
+ /** Match floating width to anchor (select listboxes). */
7
+ matchWidth?: boolean
8
+ }
9
+
10
+ /** Pin floating surface to trigger rect (for portalled overlays). */
4
11
  export function useAnchorPosition(
5
12
  anchorRef: RefObject<HTMLElement | null>,
6
13
  floatingRef: RefObject<HTMLElement | null>,
7
14
  active: boolean,
15
+ placement: AnchorPlacement = 'bottom-start',
16
+ options: AnchorPositionOptions = {},
8
17
  ) {
18
+ const { matchWidth = false } = options
19
+
9
20
  useEffect(() => {
10
21
  if (!active || !anchorRef.current || !floatingRef.current) return
11
22
 
@@ -13,21 +24,38 @@ export function useAnchorPosition(
13
24
  const anchor = anchorRef.current
14
25
  const floating = floatingRef.current
15
26
  if (!anchor || !floating) return
27
+
16
28
  const rect = anchor.getBoundingClientRect()
17
29
  floating.style.position = 'fixed'
18
- floating.style.top = `${rect.bottom + 4}px`
19
30
  floating.style.left = `${rect.left}px`
20
- floating.style.width = `${rect.width}px`
21
- floating.style.minWidth = `${rect.width}px`
31
+ floating.style.bottom = ''
32
+ floating.style.right = ''
22
33
  floating.style.zIndex = '50'
34
+
35
+ if (matchWidth) {
36
+ floating.style.width = `${rect.width}px`
37
+ floating.style.minWidth = `${rect.width}px`
38
+ } else {
39
+ floating.style.width = ''
40
+ floating.style.minWidth = ''
41
+ }
42
+
43
+ if (placement === 'top-start') {
44
+ const height = floating.offsetHeight || floating.getBoundingClientRect().height
45
+ floating.style.top = `${rect.top - height - 4}px`
46
+ } else {
47
+ floating.style.top = `${rect.bottom + 4}px`
48
+ }
23
49
  }
24
50
 
25
51
  update()
52
+ requestAnimationFrame(update)
53
+
26
54
  window.addEventListener('resize', update)
27
55
  window.addEventListener('scroll', update, true)
28
56
  return () => {
29
57
  window.removeEventListener('resize', update)
30
58
  window.removeEventListener('scroll', update, true)
31
59
  }
32
- }, [active, anchorRef, floatingRef])
60
+ }, [active, anchorRef, floatingRef, placement, matchWidth])
33
61
  }
@@ -0,0 +1,16 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ /** True when the primary input supports hover (typical mouse/trackpad). */
4
+ export function usePrefersHover() {
5
+ const [prefersHover, setPrefersHover] = useState(true)
6
+
7
+ useEffect(() => {
8
+ const mq = window.matchMedia('(hover: hover) and (pointer: fine)')
9
+ const update = () => setPrefersHover(mq.matches)
10
+ update()
11
+ mq.addEventListener('change', update)
12
+ return () => mq.removeEventListener('change', update)
13
+ }, [])
14
+
15
+ return prefersHover
16
+ }
@@ -3,6 +3,11 @@ export * from './switch/index.js'
3
3
  export * from './checkbox/index.js'
4
4
  export * from './dialog/index.js'
5
5
  export * from './tabs/index.js'
6
+ export * from './separator/index.js'
7
+ export * from './progress/index.js'
8
+ export * from './radio-group/index.js'
9
+ export * from './slider/index.js'
10
+ export * from './pin-input/index.js'
6
11
  export * from './accordion/index.js'
7
12
  export * from './async/index.js'
8
13
  export * from './field/index.js'