@edgeandnode/eds-react 1.1.1 → 1.2.0

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 (111) hide show
  1. package/README.md +27 -3
  2. package/dist/components/Address.d.ts.map +1 -1
  3. package/dist/components/Address.js +1 -1
  4. package/dist/components/Address.js.map +1 -1
  5. package/dist/components/Avatar.d.ts.map +1 -1
  6. package/dist/components/Avatar.js +13 -1
  7. package/dist/components/Avatar.js.map +1 -1
  8. package/dist/components/Breadcrumbs.parts.d.ts.map +1 -1
  9. package/dist/components/Breadcrumbs.parts.js +1 -1
  10. package/dist/components/Breadcrumbs.parts.js.map +1 -1
  11. package/dist/components/Button.js +1 -1
  12. package/dist/components/Button.js.map +1 -1
  13. package/dist/components/Card.js +1 -1
  14. package/dist/components/Card.js.map +1 -1
  15. package/dist/components/Chip.parts.d.ts.map +1 -1
  16. package/dist/components/Chip.parts.js +1 -1
  17. package/dist/components/Chip.parts.js.map +1 -1
  18. package/dist/components/Icon.d.ts.map +1 -1
  19. package/dist/components/Icon.js +0 -2
  20. package/dist/components/Icon.js.map +1 -1
  21. package/dist/components/Link.d.ts.map +1 -1
  22. package/dist/components/Link.js +2 -0
  23. package/dist/components/Link.js.map +1 -1
  24. package/dist/components/Menu.parts.js +1 -1
  25. package/dist/components/Menu.parts.js.map +1 -1
  26. package/dist/components/Search.d.ts.map +1 -1
  27. package/dist/components/Search.js +1 -1
  28. package/dist/components/Search.js.map +1 -1
  29. package/dist/components/SegmentedControl.parts.d.ts.map +1 -1
  30. package/dist/components/SegmentedControl.parts.js +1 -1
  31. package/dist/components/SegmentedControl.parts.js.map +1 -1
  32. package/dist/components/TabSet.parts.js +1 -1
  33. package/dist/components/TabSet.parts.js.map +1 -1
  34. package/dist/components/Tooltip.parts.d.ts.map +1 -1
  35. package/dist/components/Tooltip.parts.js.map +1 -1
  36. package/dist/components/base/Checkable.parts.js +1 -1
  37. package/dist/components/base/Checkable.parts.js.map +1 -1
  38. package/dist/components/base/Field.parts.js +1 -1
  39. package/dist/components/base/Field.parts.js.map +1 -1
  40. package/dist/components/base/Portal.js +3 -3
  41. package/dist/components/base/Portal.js.map +1 -1
  42. package/dist/components/base/Transition.js +1 -1
  43. package/dist/components/base/Transition.js.map +1 -1
  44. package/dist/hooks/useCSSPropsPolyfill.d.ts.map +1 -1
  45. package/dist/hooks/useCSSPropsPolyfill.js +7 -19
  46. package/dist/hooks/useCSSPropsPolyfill.js.map +1 -1
  47. package/dist/hooks/useCSSState.d.ts +4 -0
  48. package/dist/hooks/useCSSState.d.ts.map +1 -1
  49. package/dist/hooks/useCSSState.js +24 -6
  50. package/dist/hooks/useCSSState.js.map +1 -1
  51. package/dist/hooks/useStyleObserver.d.ts.map +1 -1
  52. package/dist/hooks/useStyleObserver.js +9 -5
  53. package/dist/hooks/useStyleObserver.js.map +1 -1
  54. package/dist/postcss-plugin.d.ts +2 -0
  55. package/dist/postcss-plugin.d.ts.map +1 -0
  56. package/dist/postcss-plugin.js +2 -0
  57. package/dist/postcss-plugin.js.map +1 -0
  58. package/dist/utils/internal/BatchedElementStyleObserver.d.ts +13 -0
  59. package/dist/utils/internal/BatchedElementStyleObserver.d.ts.map +1 -0
  60. package/dist/utils/internal/BatchedElementStyleObserver.js +28 -0
  61. package/dist/utils/internal/BatchedElementStyleObserver.js.map +1 -0
  62. package/dist/utils/internal/getStyleQueriesSupported.d.ts +2 -0
  63. package/dist/utils/internal/getStyleQueriesSupported.d.ts.map +1 -0
  64. package/dist/utils/internal/getStyleQueriesSupported.js +12 -0
  65. package/dist/utils/internal/getStyleQueriesSupported.js.map +1 -0
  66. package/dist/utils/internal/index.d.ts +2 -0
  67. package/dist/utils/internal/index.d.ts.map +1 -1
  68. package/dist/utils/internal/index.js +2 -0
  69. package/dist/utils/internal/index.js.map +1 -1
  70. package/dist/utils/internal/styleScheduler.d.ts +8 -3
  71. package/dist/utils/internal/styleScheduler.d.ts.map +1 -1
  72. package/dist/utils/internal/styleScheduler.js +46 -16
  73. package/dist/utils/internal/styleScheduler.js.map +1 -1
  74. package/dist/utils/styleObserver.d.ts +2 -0
  75. package/dist/utils/styleObserver.d.ts.map +1 -1
  76. package/dist/utils/styleObserver.js +40 -33
  77. package/dist/utils/styleObserver.js.map +1 -1
  78. package/dist/vite-plugin.d.ts +2 -0
  79. package/dist/vite-plugin.d.ts.map +1 -0
  80. package/dist/vite-plugin.js +2 -0
  81. package/dist/vite-plugin.js.map +1 -0
  82. package/package.json +13 -11
  83. package/scripts/update-skill.cjs +42 -0
  84. package/skill/SKILL.md +16 -12
  85. package/src/components/Address.tsx +9 -6
  86. package/src/components/Avatar.tsx +58 -44
  87. package/src/components/Breadcrumbs.parts.tsx +8 -5
  88. package/src/components/Button.tsx +1 -1
  89. package/src/components/Card.tsx +1 -1
  90. package/src/components/Chip.parts.tsx +9 -6
  91. package/src/components/Icon.tsx +0 -2
  92. package/src/components/Link.tsx +2 -0
  93. package/src/components/Menu.parts.tsx +1 -1
  94. package/src/components/Search.tsx +9 -6
  95. package/src/components/SegmentedControl.parts.tsx +8 -5
  96. package/src/components/TabSet.parts.tsx +1 -1
  97. package/src/components/Tooltip.parts.tsx +4 -1
  98. package/src/components/base/Checkable.parts.tsx +1 -1
  99. package/src/components/base/Field.parts.tsx +1 -1
  100. package/src/components/base/Portal.tsx +3 -3
  101. package/src/components/base/Transition.tsx +1 -1
  102. package/src/hooks/useCSSPropsPolyfill.ts +7 -20
  103. package/src/hooks/useCSSState.ts +29 -8
  104. package/src/hooks/useStyleObserver.ts +18 -14
  105. package/src/postcss-plugin.ts +1 -0
  106. package/src/utils/internal/BatchedElementStyleObserver.ts +31 -0
  107. package/src/utils/internal/getStyleQueriesSupported.ts +11 -0
  108. package/src/utils/internal/index.ts +2 -0
  109. package/src/utils/internal/styleScheduler.ts +43 -15
  110. package/src/utils/styleObserver.ts +38 -40
  111. package/src/vite-plugin.ts +1 -0
@@ -13,6 +13,7 @@ import { cn, getAssetUrl, getCSSPropsAttributes } from '../utils/index.ts'
13
13
  import { getTooltipProps } from '../utils/internal/index.ts'
14
14
  import { AvatarMeta } from './Avatar.meta.ts'
15
15
  import { MaybeButtonOrLink, type MaybeButtonOrLinkProps } from './base/MaybeButtonOrLink.tsx'
16
+ import { Render } from './base/Render.tsx'
16
17
  import { Tooltip, type TooltipProps } from './Tooltip.tsx'
17
18
 
18
19
  const PLACEHOLDER_SRC_CIRCLE = createIdenticon(PLACEHOLDER_ADDRESS)
@@ -94,55 +95,68 @@ export function Avatar({
94
95
  props,
95
96
  )}
96
97
  render={(renderProps, { Element, elementProps, category }) => {
98
+ const avatar = (
99
+ <Element {...elementProps}>
100
+ <span className="nested/avatar-paint pointer-events-none shrink-0">
101
+ <span
102
+ className="
103
+ block overflow-clip bg-default
104
+ group-clickable-focus-visible/avatar:outline
105
+ @prop-size-xsmall/avatar:size-4
106
+ @prop-size-small/avatar:size-5
107
+ @prop-size-medium/avatar:size-6
108
+ @prop-size-large/avatar:size-8
109
+ @prop-size-xlarge/avatar:size-12
110
+ @prop-shape-circle/avatar:rounded-full
111
+ @prop-shape-rounded/avatar:rounded-4
112
+ @prop-shape-rounded/avatar:@prop-size-xsmall/avatar:rounded-2
113
+ @prop-shape-rounded/avatar:@prop-size-xlarge/avatar:rounded-8
114
+ uu:*:absolute
115
+ uu:*:inset-0
116
+ uu:*:m-auto
117
+ uu:*:size-full
118
+ uu:*:object-cover
119
+ i:@prop-size-xsmall/avatar:**:icon:default-size-2.5
120
+ i:@prop-size-small/avatar:**:icon:default-size-3
121
+ i:@prop-size-medium/avatar:**:icon:default-size-4
122
+ i:@prop-size-large/avatar:**:icon:default-size-5
123
+ i:@prop-size-xlarge/avatar:**:icon:default-size-8
124
+ "
125
+ >
126
+ {src ? (
127
+ typeof src === 'string' ? (
128
+ <img
129
+ src={src}
130
+ alt=""
131
+ onError={() => setCurrentSourceIndex(currentSourceIndex + 1)}
132
+ className="[[src^='data:image/bmp']]:[image-rendering:pixelated]"
133
+ />
134
+ ) : (
135
+ <Tooltip.Collector>{src}</Tooltip.Collector>
136
+ )
137
+ ) : null}
138
+ {alt ? <span className="sr-only">{alt}</span> : null}
139
+ </span>
140
+ </span>
141
+ </Element>
142
+ )
143
+ const clickable = category !== 'other'
144
+ const focusable = clickable || props.as !== undefined || props.tabIndex !== undefined
145
+ /**
146
+ * Without any content to show (or collect from `src`), a `Tooltip` would only cost its
147
+ * trigger and observers. Avatars that can take focus keep it anyway, since adding or
148
+ * removing it remounts the element, which would lose focus.
149
+ */
150
+ if (!focusable && tooltip === undefined && typeof src === 'string') {
151
+ return <Render render={avatar} {...renderProps} />
152
+ }
97
153
  return (
98
154
  <Tooltip
99
- content={category !== 'other' ? alt : undefined}
155
+ content={clickable ? alt : undefined}
100
156
  {...getTooltipProps(tooltip)}
101
157
  triggerProps={renderProps}
102
158
  >
103
- <Element {...elementProps}>
104
- <span className="nested/avatar-paint pointer-events-none shrink-0">
105
- <span
106
- className="
107
- block overflow-clip bg-default
108
- group-clickable-focus-visible/avatar:outline
109
- @prop-size-xsmall/avatar:size-4
110
- @prop-size-small/avatar:size-5
111
- @prop-size-medium/avatar:size-6
112
- @prop-size-large/avatar:size-8
113
- @prop-size-xlarge/avatar:size-12
114
- @prop-shape-circle/avatar:rounded-full
115
- @prop-shape-rounded/avatar:rounded-4
116
- @prop-shape-rounded/avatar:@prop-size-xsmall/avatar:rounded-2
117
- @prop-shape-rounded/avatar:@prop-size-xlarge/avatar:rounded-8
118
- uu:*:absolute
119
- uu:*:inset-0
120
- uu:*:m-auto
121
- uu:*:size-full
122
- uu:*:object-cover
123
- i:@prop-size-xsmall/avatar:**:icon:default-size-2.5
124
- i:@prop-size-small/avatar:**:icon:default-size-3
125
- i:@prop-size-medium/avatar:**:icon:default-size-4
126
- i:@prop-size-large/avatar:**:icon:default-size-5
127
- i:@prop-size-xlarge/avatar:**:icon:default-size-8
128
- "
129
- >
130
- {src ? (
131
- typeof src === 'string' ? (
132
- <img
133
- src={src}
134
- alt=""
135
- onError={() => setCurrentSourceIndex(currentSourceIndex + 1)}
136
- className="[[src^='data:image/bmp']]:[image-rendering:pixelated]"
137
- />
138
- ) : (
139
- <Tooltip.Collector>{src}</Tooltip.Collector>
140
- )
141
- ) : null}
142
- {alt ? <span className="sr-only">{alt}</span> : null}
143
- </span>
144
- </span>
145
- </Element>
159
+ {avatar}
146
160
  </Tooltip>
147
161
  )
148
162
  }}
@@ -290,11 +290,14 @@ export function BreadcrumbsItem({
290
290
  const current = passedCurrent ?? isLastItem
291
291
  const { rootProps, nestedProps } = splitProps(props)
292
292
 
293
- const [stateRef, state] = useCSSState({
294
- pointer: undefined,
295
- focus: undefined,
296
- current,
297
- })
293
+ const [stateRef, state] = useCSSState(
294
+ {
295
+ pointer: undefined,
296
+ focus: undefined,
297
+ current,
298
+ },
299
+ { returnState: false },
300
+ )
298
301
  const [cssPropsPolyfillStatePassedRef, cssPropsPolyfillAttributes] = useCSSPropsPolyfill(
299
302
  BreadcrumbsItemMeta,
300
303
  { hideLabel },
@@ -76,7 +76,7 @@ export function Button({
76
76
  disabled: Boolean(disabled),
77
77
  'addon-only': !children ? 'auto' : '0',
78
78
  },
79
- { ref: passedRef },
79
+ { ref: passedRef, returnState: false, exposeState: false },
80
80
  )
81
81
  const [cssPropsPolyfillStatePassedRef, cssPropsPolyfillAttributes, cssProps] =
82
82
  useCSSPropsPolyfill(
@@ -72,7 +72,7 @@ export function Card({ ref: passedRef, as, variant, disabled, children, ...props
72
72
  checked: props.checked,
73
73
  disabled: Boolean(disabled),
74
74
  },
75
- { ref: passedRef },
75
+ { ref: passedRef, returnState: false },
76
76
  )
77
77
  const [cssPropsPolyfillStatePassedRef, cssPropsPolyfillAttributes] = useCSSPropsPolyfill(
78
78
  CardMeta,
@@ -65,12 +65,15 @@ export function ChipRoot({
65
65
 
66
66
  const groupContext = useContext(ChipGroupContext)
67
67
 
68
- const [stateRef, state] = useCSSState({
69
- pointer: undefined,
70
- focus: undefined,
71
- checked: undefined,
72
- disabled: Boolean(disabled),
73
- })
68
+ const [stateRef, state] = useCSSState(
69
+ {
70
+ pointer: undefined,
71
+ focus: undefined,
72
+ checked: undefined,
73
+ disabled: Boolean(disabled),
74
+ },
75
+ { returnState: false },
76
+ )
74
77
  const [cssPropsPolyfillStateRef, cssPropsPolyfillAttributes] = useCSSPropsPolyfill(
75
78
  ChipMeta,
76
79
  { size },
@@ -54,8 +54,6 @@ export function Icon({ src, alt, size, color, ...props }: IconPropsWithSrc) {
54
54
  {
55
55
  className: cn(`
56
56
  eds-icon root-flex items-center justify-center
57
- ${/* Fix icons wobbling in Safari when rendered on top of opacity/background transitions */ ''}
58
- u:will-change-transform
59
57
  ${/* Flip some icons horizontally in RTL so that `ArrowLeft` points right, etc. */ ''}
60
58
  rtl:data-[name*=Left]:*:-scale-x-100
61
59
  rtl:data-[name*=Right]:*:-scale-x-100
@@ -54,6 +54,8 @@ export function Link({
54
54
  },
55
55
  {
56
56
  ref: passedRef,
57
+ returnState: false,
58
+ exposeState: false,
57
59
  },
58
60
  )
59
61
  const [cssPropsPolyfillStatePassedRef, cssPropsPolyfillAttributes, cssProps] =
@@ -1015,7 +1015,7 @@ export function MenuItem<T extends OptionValue>({
1015
1015
  checked,
1016
1016
  disabled,
1017
1017
  },
1018
- { ref: elementPassedRef },
1018
+ { ref: elementPassedRef, returnState: false, exposeState: false },
1019
1019
  )
1020
1020
 
1021
1021
  const groupContext = useContext(MenuGroupContext)
@@ -54,12 +54,15 @@ export function Search({
54
54
  const previousActiveElementRef = useRef<HTMLElement | null>(null)
55
55
  const [value, setValue] = useControlled(controlledValue, defaultValue ?? '', onValueChange)
56
56
 
57
- const [stateRef, state] = useCSSState({
58
- pointer: undefined,
59
- focus: undefined,
60
- blank: value === '',
61
- open: value !== '',
62
- })
57
+ const [stateRef, state] = useCSSState(
58
+ {
59
+ pointer: undefined,
60
+ focus: undefined,
61
+ blank: value === '',
62
+ open: value !== '',
63
+ },
64
+ { returnState: false },
65
+ )
63
66
  const [cssPropsPolyfillStateRef, cssPropsPolyfillAttributes, cssProps] = useCSSPropsPolyfill(
64
67
  SearchMeta,
65
68
  { size, layout },
@@ -152,11 +152,14 @@ export function SegmentedControlOption<T extends OptionValue>({
152
152
  const autoValue = useAutoValue(children)
153
153
  const value = passedValue !== undefined ? passedValue : autoValue
154
154
 
155
- const [stateRef, state] = useCSSState({
156
- pointer: undefined,
157
- checked: undefined,
158
- 'is-toggle': undefined,
159
- })
155
+ const [stateRef, state] = useCSSState(
156
+ {
157
+ pointer: undefined,
158
+ checked: undefined,
159
+ 'is-toggle': undefined,
160
+ },
161
+ { returnState: false },
162
+ )
160
163
  const [cssPropsPolyfillStatePassedRef, cssPropsPolyfillAttributes] = useCSSPropsPolyfill(
161
164
  SegmentedControlOptionMeta,
162
165
  { hideLabel },
@@ -203,7 +203,7 @@ export function TabSetTab({
203
203
  checked: selected,
204
204
  disabled: Boolean(disabled),
205
205
  },
206
- { ref: passedRef },
206
+ { ref: passedRef, returnState: false, exposeState: false },
207
207
  )
208
208
  const [cssPropsPolyfillStatePassedRef, cssPropsPolyfillAttributes] = useCSSPropsPolyfill(
209
209
  TabSetTabMeta,
@@ -176,7 +176,10 @@ export function TooltipRoot({
176
176
  <Render render={children} {...renderProps} disabled={triggerDisabled} />
177
177
  )}
178
178
  />
179
- {/* Ensure changes to CSS props are detected even when the tooltip is not mounted */}
179
+ {/**
180
+ * Observe CSS props even without content: changing `disabled` can make a descendant's collected
181
+ * content available to another tooltip.
182
+ */}
180
183
  <Portal>
181
184
  <span
182
185
  ref={cssPropsPolyfillRef}
@@ -209,7 +209,7 @@ export function CheckableLabel({
209
209
 
210
210
  const [statePassedRef, state] = useCSSState(
211
211
  { pointer: undefined, focus: undefined, checked, disabled },
212
- { ref: passedRef },
212
+ { ref: passedRef, returnState: false },
213
213
  )
214
214
 
215
215
  return (
@@ -107,7 +107,7 @@ export const FieldRoot = ({
107
107
  blank: typeof value === 'object' ? value.optionValue === null : !value,
108
108
  disabled,
109
109
  },
110
- { ref: passedRef },
110
+ { ref: passedRef, returnState: false },
111
111
  )
112
112
  const [cssPropsPolyfillStatePassedRef, cssPropsPolyfillAttributes] = useCSSPropsPolyfill(
113
113
  FieldMeta,
@@ -3,13 +3,13 @@
3
3
  import type { ReactNode } from 'react'
4
4
  import { createPortal } from 'react-dom'
5
5
 
6
- import { useFirstRender } from '../../hooks/index.ts'
6
+ import { useIsHydrated } from '../../hooks/index.ts'
7
7
 
8
8
  export function Portal({ children }: { children: ReactNode }) {
9
- const firstRender = useFirstRender({ immediatelyRerender: true })
9
+ const hydrated = useIsHydrated()
10
10
 
11
11
  // Don't render until after hydration
12
- if (firstRender) {
12
+ if (!hydrated) {
13
13
  return null
14
14
  }
15
15
 
@@ -94,7 +94,7 @@ export function Transition({
94
94
  {
95
95
  transition: 'idle',
96
96
  },
97
- { ref: passedRef },
97
+ { ref: passedRef, returnState: false, exposeState: false },
98
98
  )
99
99
 
100
100
  // Collapse to the early-exit branch under reduced-motion — `global.css`'s `@media` block can't skip the cross-fade structure (both `<m.span>`s mount during a swap), only `duration <= 0` can.
@@ -9,8 +9,9 @@ import {
9
9
  type EDSComponent,
10
10
  } from '@edgeandnode/eds-css'
11
11
 
12
+ import { getStyleQueriesSupported } from '../utils/internal/index.ts'
12
13
  import { useCSSProps, type PassedValues, type PropValues } from './useCSSProps.ts'
13
- import { useFirstRender } from './useFirstRender.ts'
14
+ import { useIsHydrated } from './useIsHydrated.ts'
14
15
 
15
16
  type ExtractPolyfillValues<P extends CSSPropsConfig> = {
16
17
  [K in keyof P as P[K]['type'] extends 'values' ? K : never]: CSSPropType<P[K]> | undefined
@@ -114,16 +115,14 @@ export function useCSSPropsPolyfill<
114
115
  })
115
116
 
116
117
  /**
117
- * Always include the polyfill attributes on the first render (even if the polyfill is disabled)
118
- * to prevent hydration errors. It would be nice to optimize for the case where the polyfill is
119
- * not needed instead (by always excluding the attributes on the first render, thereby not having
120
- * to immediately rerender in browsers that support style queries); unfortunately, that would
121
- * cause a flash of missing styles in browsers that need the polyfill.
118
+ * Include the polyfill attributes until hydration completes (even if the polyfill is disabled) to
119
+ * prevent hydration errors. Components mounted after hydration (e.g. on client-side navigation)
120
+ * skip them, so they don't have to rerender (and restyle their subtree) just to remove them.
122
121
  */
123
- const firstRender = useFirstRender({ immediatelyRerender: !polyfillEnabled })
122
+ const hydrated = useIsHydrated()
124
123
  const polyfillAttributes = cssPropsOverride
125
124
  ? cssPropsOverride.polyfillAttributes
126
- : polyfillEnabled || firstRender
125
+ : polyfillEnabled || !hydrated
127
126
  ? Object.fromEntries(
128
127
  cssProps
129
128
  .filter((prop) => prop.name in passedValues)
@@ -148,15 +147,3 @@ export function useCSSPropsPolyfill<
148
147
 
149
148
  return [callbackRef, polyfillAttributes, returnedPropValues] as const
150
149
  }
151
-
152
- let privateStyleQueriesSupported: boolean | null = null
153
-
154
- function getStyleQueriesSupported(): boolean {
155
- if (typeof window === 'undefined' || typeof document === 'undefined') return false
156
- if (privateStyleQueriesSupported === null) {
157
- privateStyleQueriesSupported =
158
- window.getComputedStyle(document.body).getPropertyValue('--eds-style-queries-test') ===
159
- 'success'
160
- }
161
- return privateStyleQueriesSupported
162
- }
@@ -2,6 +2,8 @@ import type { Ref } from 'react'
2
2
 
3
3
  import { cssStateVariables } from '@edgeandnode/eds-css'
4
4
 
5
+ import { getStyleQueriesSupported } from '../utils/internal/index.ts'
6
+ import { useIsHydrated } from './useIsHydrated.ts'
5
7
  import { useMapWithInit } from './useMapWithInit.ts'
6
8
  import { useRefWithInit } from './useRefWithInit.ts'
7
9
  import { useStyleObserver } from './useStyleObserver.ts'
@@ -50,9 +52,16 @@ export function useCSSState<
50
52
  options: {
51
53
  ref?: Ref<HTMLElement> | undefined
52
54
  onChange?: (state: CSSStateType<T>) => void
55
+ /** Set to `false` when only the attributes are consumed; the polyfill still observes state. */
56
+ returnState?: boolean | undefined
57
+ /** Set to `false` when the component does not spread `exposedAttributes` or `allAttributes`. */
58
+ exposeState?: boolean | undefined
53
59
  } = {},
54
60
  ) {
55
- const { ref, onChange } = options
61
+ const { ref, onChange, returnState = true, exposeState = true } = options
62
+ const polyfillEnabled = !getStyleQueriesSupported()
63
+ const hydrated = useIsHydrated()
64
+ const observeState = returnState || Boolean(onChange) || polyfillEnabled
56
65
 
57
66
  const variables = useRefWithInit(() =>
58
67
  Object.keys(variableInitialValues).map((variableName) => {
@@ -60,10 +69,21 @@ export function useCSSState<
60
69
  const defaultValue = !isArbitrary
61
70
  ? cssStateVariables[variableName as CSSStateVariableName].defaultValue
62
71
  : undefined
72
+ const passedValue = variableInitialValues[variableName as keyof T]
63
73
  return {
64
74
  name: variableName,
65
75
  defaultValue,
66
- initialValue: variableInitialValues[variableName as keyof T] ?? defaultValue,
76
+ initialValue: passedValue ?? defaultValue,
77
+ /**
78
+ * Nothing is exposed until CSS says otherwise, and a missing `data-eds-exposed-*` attribute
79
+ * falls back to the native state. Starting from the default value instead would make the
80
+ * first style observation remove the attribute, rerendering the component and restyling its
81
+ * whole subtree.
82
+ */
83
+ initialExposedValue:
84
+ passedValue === undefined || String(passedValue) === String(defaultValue)
85
+ ? undefined
86
+ : passedValue,
67
87
  observedProperty: isArbitrary
68
88
  ? `--eds-state-${variableName}`
69
89
  : `--eds-observed-${variableName}`,
@@ -74,14 +94,15 @@ export function useCSSState<
74
94
  }),
75
95
  ).current
76
96
 
77
- const properties = useRefWithInit(() =>
78
- variables.flatMap((variable) => [variable.observedProperty, variable.exposedProperty]),
79
- ).current
97
+ const properties = variables.flatMap((variable) => [
98
+ ...(observeState ? [variable.observedProperty] : []),
99
+ ...(exposeState ? [variable.exposedProperty] : []),
100
+ ])
80
101
 
81
102
  const propertyValues = useMapWithInit(() =>
82
103
  variables.flatMap((variable) => [
83
104
  [variable.observedProperty, String(variable.initialValue ?? '')],
84
- [variable.exposedProperty, String(variable.initialValue ?? '')],
105
+ [variable.exposedProperty, String(variable.initialExposedValue ?? '')],
85
106
  ]),
86
107
  )
87
108
 
@@ -131,12 +152,12 @@ export function useCSSState<
131
152
  }>(
132
153
  (result, variable) => {
133
154
  const exposedValue = getStateValue(variable, 'exposed')
134
- if (exposedValue !== undefined) {
155
+ if (exposeState && exposedValue !== undefined) {
135
156
  result.exposedAttributes[variable.exposedAttribute] = String(exposedValue)
136
157
  result.allAttributes[variable.exposedAttribute] = String(exposedValue)
137
158
  }
138
159
  const observedValue = getStateValue(variable, 'observed')
139
- if (observedValue !== undefined) {
160
+ if ((polyfillEnabled || !hydrated) && observedValue !== undefined) {
140
161
  result.polyfillAttributes[variable.polyfillAttribute] = String(observedValue)
141
162
  result.allAttributes[variable.polyfillAttribute] = String(observedValue)
142
163
  }
@@ -32,25 +32,29 @@ export function useStyleObserver<T extends string>(
32
32
  if (!element || properties.length === 0 || disabled) return
33
33
 
34
34
  // TODO: Use `style-observer`'s `immediate` option when it exists: https://github.com/LeaVerou/style-observer/issues/45
35
- if (immediate) {
36
- styleScheduler.schedule(() => {
37
- const computedStyle = window.getComputedStyle(element)
38
- memoizedOnChange(
39
- properties.map((property) => ({
40
- target: element,
41
- property,
42
- value: computedStyle.getPropertyValue(property),
43
- oldValue: '',
44
- })),
45
- )
46
- })
47
- }
35
+ const cancelImmediate = immediate
36
+ ? styleScheduler.schedule(() => {
37
+ const computedStyle = window.getComputedStyle(element)
38
+ memoizedOnChange(
39
+ properties.map((property) => ({
40
+ target: element,
41
+ property,
42
+ value: computedStyle.getPropertyValue(property),
43
+ oldValue: '',
44
+ })),
45
+ )
46
+ })
47
+ : undefined
48
48
 
49
- return styleObserver.subscribe(
49
+ const unsubscribe = styleObserver.subscribe(
50
50
  element,
51
51
  properties,
52
52
  memoizedOnChange as (records: StyleObserverRecord[]) => void,
53
53
  )
54
+ return () => {
55
+ cancelImmediate?.()
56
+ unsubscribe()
57
+ }
54
58
  },
55
59
  [properties, memoizedOnChange, immediate, disabled],
56
60
  [watchedRef],
@@ -0,0 +1 @@
1
+ export { default } from '@edgeandnode/eds-css/postcss-plugin'
@@ -0,0 +1,31 @@
1
+ import { ElementStyleObserver } from 'style-observer'
2
+
3
+ /**
4
+ * `style-observer` reads computed styles between its transition writes on each new target. Buffer
5
+ * those first writes so every target can read its original styles before any transitions change.
6
+ * Subsequent updates retain the library's transition and browser compatibility handling.
7
+ */
8
+ export class BatchedElementStyleObserver extends ElementStyleObserver {
9
+ private pendingStyles: Map<string, { value: string; priority: string | undefined }> | undefined =
10
+ new Map()
11
+
12
+ override setProperty(property: string, value: string, priority?: string) {
13
+ if (this.pendingStyles) {
14
+ this.pendingStyles.set(property, { value, priority })
15
+ } else {
16
+ super.setProperty(property, value, priority)
17
+ }
18
+ }
19
+
20
+ override getProperty(property: string) {
21
+ return this.pendingStyles?.get(property)?.value ?? super.getProperty(property)
22
+ }
23
+
24
+ flush() {
25
+ const pendingStyles = this.pendingStyles
26
+ this.pendingStyles = undefined
27
+ for (const [property, { value, priority }] of pendingStyles ?? []) {
28
+ super.setProperty(property, value, priority)
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,11 @@
1
+ let styleQueriesSupported: boolean | null = null
2
+
3
+ export function getStyleQueriesSupported(): boolean {
4
+ if (typeof window === 'undefined' || typeof document === 'undefined') return false
5
+ if (styleQueriesSupported === null) {
6
+ styleQueriesSupported =
7
+ window.getComputedStyle(document.body).getPropertyValue('--eds-style-queries-test') ===
8
+ 'success'
9
+ }
10
+ return styleQueriesSupported
11
+ }
@@ -1,3 +1,5 @@
1
1
  export { getTooltipProps } from './getTooltipProps.ts'
2
2
  export { isLazy, resolveLazy } from './resolveLazy.ts'
3
3
  export { styleScheduler } from './styleScheduler.ts'
4
+ export { BatchedElementStyleObserver } from './BatchedElementStyleObserver.ts'
5
+ export { getStyleQueriesSupported } from './getStyleQueriesSupported.ts'
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Batched style scheduler that collects style operations within a single frame and executes them
3
- * together using `requestAnimationFrame`. This reduces layout thrashing by batching all operations
4
- * into a single reflow.
3
+ * together using `requestAnimationFrame`. Reads run before writes to avoid layout thrashing.
5
4
  *
6
5
  * @example
7
6
  * ````ts
@@ -23,8 +22,9 @@
23
22
  */
24
23
 
25
24
  class StyleScheduler {
26
- private rafId: number | null = null
27
- private pending: (() => void)[] = []
25
+ private animationFrameId: number | null = null
26
+ private pending = new Set<() => void>()
27
+ private pendingWrites = new Set<() => void>()
28
28
 
29
29
  /**
30
30
  * Schedule an operation to be executed in the next animation frame. All operations scheduled
@@ -32,24 +32,52 @@ class StyleScheduler {
32
32
  * Returns a function that cancels the scheduled callback if it hasn't run yet.
33
33
  */
34
34
  schedule(callback: () => void): () => void {
35
- this.pending.push(callback)
35
+ return this.enqueue(callback, this.pending)
36
+ }
36
37
 
37
- if (this.rafId === null) {
38
- this.rafId = requestAnimationFrame(() => {
39
- const callbacks = this.pending
40
- this.pending = []
41
- this.rafId = null
38
+ /** Schedule a write after all reads in the frame, including writes queued during those reads. */
39
+ scheduleWrite(callback: () => void): () => void {
40
+ return this.enqueue(callback, this.pendingWrites)
41
+ }
42
42
 
43
- callbacks.forEach((cb) => cb())
44
- })
43
+ private enqueue(callback: () => void, queue: Set<() => void>): () => void {
44
+ const operation = () => callback()
45
+ queue.add(operation)
46
+ this.requestFrame()
47
+ return () => {
48
+ queue.delete(operation)
45
49
  }
50
+ }
46
51
 
47
- return () => {
48
- const index = this.pending.indexOf(callback)
49
- if (index !== -1) this.pending.splice(index, 1)
52
+ private runBatch(queue: Set<() => void>) {
53
+ try {
54
+ for (const callback of queue) {
55
+ queue.delete(callback)
56
+ callback()
57
+ }
58
+ } finally {
59
+ // Cancellation closures can outlive the batch, including when a callback throws.
60
+ queue.clear()
50
61
  }
51
62
  }
52
63
 
64
+ private requestFrame() {
65
+ if (this.animationFrameId !== null) return
66
+ this.animationFrameId = requestAnimationFrame(() => {
67
+ const callbacks = this.pending
68
+ this.pending = new Set()
69
+ try {
70
+ this.runBatch(callbacks)
71
+ const writes = this.pendingWrites
72
+ this.pendingWrites = new Set()
73
+ this.runBatch(writes)
74
+ } finally {
75
+ this.animationFrameId = null
76
+ if (this.pending.size > 0 || this.pendingWrites.size > 0) this.requestFrame()
77
+ }
78
+ })
79
+ }
80
+
53
81
  /**
54
82
  * Get computed style for an element in a batched manner. Returns a promise that resolves with the
55
83
  * computed style once all batched operations are executed.