@bgub/fig-headless 0.0.0-stage → 0.1.0-alpha.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 (174) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +595 -2
  4. package/dist/accordion.d.ts +46 -0
  5. package/dist/accordion.js +2 -0
  6. package/dist/accordion.js.map +1 -0
  7. package/dist/changes--uvVfvy8.d.ts +11 -0
  8. package/dist/changes-DglRuvQa.js +2 -0
  9. package/dist/changes-DglRuvQa.js.map +1 -0
  10. package/dist/checkbox.d.ts +24 -0
  11. package/dist/checkbox.js +2 -0
  12. package/dist/checkbox.js.map +1 -0
  13. package/dist/combobox.d.ts +53 -0
  14. package/dist/combobox.js +2 -0
  15. package/dist/combobox.js.map +1 -0
  16. package/dist/command.d.ts +42 -0
  17. package/dist/command.js +2 -0
  18. package/dist/command.js.map +1 -0
  19. package/dist/composite-CG4JghHi.d.ts +4 -0
  20. package/dist/composite-CiKLX-Ti.js +2 -0
  21. package/dist/composite-CiKLX-Ti.js.map +1 -0
  22. package/dist/controllable-value-CDbRzlId.js +2 -0
  23. package/dist/controllable-value-CDbRzlId.js.map +1 -0
  24. package/dist/diagnostics-CoURcwqb.js +2 -0
  25. package/dist/diagnostics-CoURcwqb.js.map +1 -0
  26. package/dist/dialog.d.ts +44 -0
  27. package/dist/dialog.js +2 -0
  28. package/dist/dialog.js.map +1 -0
  29. package/dist/field.d.ts +35 -0
  30. package/dist/field.js +2 -0
  31. package/dist/field.js.map +1 -0
  32. package/dist/form-reset-B7G3-0OT.js +2 -0
  33. package/dist/form-reset-B7G3-0OT.js.map +1 -0
  34. package/dist/hover-HF7JXkR2.js +2 -0
  35. package/dist/hover-HF7JXkR2.js.map +1 -0
  36. package/dist/ids-Cvu36mFL.js +2 -0
  37. package/dist/ids-Cvu36mFL.js.map +1 -0
  38. package/dist/listbox-DcD6dHSg.js +2 -0
  39. package/dist/listbox-DcD6dHSg.js.map +1 -0
  40. package/dist/listbox.d.ts +32 -0
  41. package/dist/listbox.js +2 -0
  42. package/dist/listbox.js.map +1 -0
  43. package/dist/menu/context-menu.d.ts +15 -0
  44. package/dist/menu/context-menu.js +2 -0
  45. package/dist/menu/context-menu.js.map +1 -0
  46. package/dist/menu/submenu.d.ts +20 -0
  47. package/dist/menu/submenu.js +2 -0
  48. package/dist/menu/submenu.js.map +1 -0
  49. package/dist/menu-B4pKuF3z.js +2 -0
  50. package/dist/menu-B4pKuF3z.js.map +1 -0
  51. package/dist/menu.d.ts +52 -0
  52. package/dist/menu.js +1 -0
  53. package/dist/open-state-BmP__rp4.d.ts +6 -0
  54. package/dist/open-state-G79p9WiE.js +2 -0
  55. package/dist/open-state-G79p9WiE.js.map +1 -0
  56. package/dist/part-reference-B7zINPUK.js +2 -0
  57. package/dist/part-reference-B7zINPUK.js.map +1 -0
  58. package/dist/parts-I0jdefyp.js +2 -0
  59. package/dist/parts-I0jdefyp.js.map +1 -0
  60. package/dist/popover-CSPJNmHy.d.ts +37 -0
  61. package/dist/popover-UfJHN1h2.js +2 -0
  62. package/dist/popover-UfJHN1h2.js.map +1 -0
  63. package/dist/popover.d.ts +2 -0
  64. package/dist/popover.js +1 -0
  65. package/dist/popup/position.d.ts +26 -0
  66. package/dist/popup/position.js +2 -0
  67. package/dist/popup/position.js.map +1 -0
  68. package/dist/popup-state-CFSmDEwK.js +2 -0
  69. package/dist/popup-state-CFSmDEwK.js.map +1 -0
  70. package/dist/radio-group.d.ts +52 -0
  71. package/dist/radio-group.js +2 -0
  72. package/dist/radio-group.js.map +1 -0
  73. package/dist/relations-uMHtgW03.js +2 -0
  74. package/dist/relations-uMHtgW03.js.map +1 -0
  75. package/dist/select.d.ts +45 -0
  76. package/dist/select.js +2 -0
  77. package/dist/select.js.map +1 -0
  78. package/dist/slider.d.ts +35 -0
  79. package/dist/slider.js +2 -0
  80. package/dist/slider.js.map +1 -0
  81. package/dist/switch.d.ts +24 -0
  82. package/dist/switch.js +2 -0
  83. package/dist/switch.js.map +1 -0
  84. package/dist/tabs/indicator.d.ts +20 -0
  85. package/dist/tabs/indicator.js +2 -0
  86. package/dist/tabs/indicator.js.map +1 -0
  87. package/dist/tabs.d.ts +49 -0
  88. package/dist/tabs.js +2 -0
  89. package/dist/tabs.js.map +1 -0
  90. package/dist/toast.d.ts +34 -0
  91. package/dist/toast.js +2 -0
  92. package/dist/toast.js.map +1 -0
  93. package/dist/toggle-B0hOxshF.js +2 -0
  94. package/dist/toggle-B0hOxshF.js.map +1 -0
  95. package/dist/toggle-q6B-U4Nq.d.ts +29 -0
  96. package/dist/toolbar.d.ts +25 -0
  97. package/dist/toolbar.js +2 -0
  98. package/dist/toolbar.js.map +1 -0
  99. package/dist/tooltip.d.ts +31 -0
  100. package/dist/tooltip.js +2 -0
  101. package/dist/tooltip.js.map +1 -0
  102. package/dist-development/accordion.js +2 -0
  103. package/dist-development/accordion.js.map +1 -0
  104. package/dist-development/changes-DglRuvQa.js +2 -0
  105. package/dist-development/changes-DglRuvQa.js.map +1 -0
  106. package/dist-development/checkbox.js +2 -0
  107. package/dist-development/checkbox.js.map +1 -0
  108. package/dist-development/combobox.js +2 -0
  109. package/dist-development/combobox.js.map +1 -0
  110. package/dist-development/command.js +2 -0
  111. package/dist-development/command.js.map +1 -0
  112. package/dist-development/composite-DoFLgUjR.js +2 -0
  113. package/dist-development/composite-DoFLgUjR.js.map +1 -0
  114. package/dist-development/controllable-value-CDbRzlId.js +2 -0
  115. package/dist-development/controllable-value-CDbRzlId.js.map +1 -0
  116. package/dist-development/diagnostics-BkQV0MaT.js +2 -0
  117. package/dist-development/diagnostics-BkQV0MaT.js.map +1 -0
  118. package/dist-development/dialog.js +2 -0
  119. package/dist-development/dialog.js.map +1 -0
  120. package/dist-development/field.js +2 -0
  121. package/dist-development/field.js.map +1 -0
  122. package/dist-development/form-reset-B7G3-0OT.js +2 -0
  123. package/dist-development/form-reset-B7G3-0OT.js.map +1 -0
  124. package/dist-development/hover-HF7JXkR2.js +2 -0
  125. package/dist-development/hover-HF7JXkR2.js.map +1 -0
  126. package/dist-development/ids-Cvu36mFL.js +2 -0
  127. package/dist-development/ids-Cvu36mFL.js.map +1 -0
  128. package/dist-development/listbox-B-oe_0c0.js +2 -0
  129. package/dist-development/listbox-B-oe_0c0.js.map +1 -0
  130. package/dist-development/listbox.js +2 -0
  131. package/dist-development/listbox.js.map +1 -0
  132. package/dist-development/menu/context-menu.js +2 -0
  133. package/dist-development/menu/context-menu.js.map +1 -0
  134. package/dist-development/menu/submenu.js +2 -0
  135. package/dist-development/menu/submenu.js.map +1 -0
  136. package/dist-development/menu-DBxBFdYm.js +2 -0
  137. package/dist-development/menu-DBxBFdYm.js.map +1 -0
  138. package/dist-development/menu.js +1 -0
  139. package/dist-development/open-state-G79p9WiE.js +2 -0
  140. package/dist-development/open-state-G79p9WiE.js.map +1 -0
  141. package/dist-development/part-reference-B7zINPUK.js +2 -0
  142. package/dist-development/part-reference-B7zINPUK.js.map +1 -0
  143. package/dist-development/parts-I0jdefyp.js +2 -0
  144. package/dist-development/parts-I0jdefyp.js.map +1 -0
  145. package/dist-development/popover-DXxGM3tO.js +2 -0
  146. package/dist-development/popover-DXxGM3tO.js.map +1 -0
  147. package/dist-development/popover.js +1 -0
  148. package/dist-development/popup/position.js +2 -0
  149. package/dist-development/popup/position.js.map +1 -0
  150. package/dist-development/popup-state-BUYfC2nG.js +2 -0
  151. package/dist-development/popup-state-BUYfC2nG.js.map +1 -0
  152. package/dist-development/radio-group.js +2 -0
  153. package/dist-development/radio-group.js.map +1 -0
  154. package/dist-development/relations-CZpjLINW.js +2 -0
  155. package/dist-development/relations-CZpjLINW.js.map +1 -0
  156. package/dist-development/select.js +2 -0
  157. package/dist-development/select.js.map +1 -0
  158. package/dist-development/slider.js +2 -0
  159. package/dist-development/slider.js.map +1 -0
  160. package/dist-development/switch.js +2 -0
  161. package/dist-development/switch.js.map +1 -0
  162. package/dist-development/tabs/indicator.js +2 -0
  163. package/dist-development/tabs/indicator.js.map +1 -0
  164. package/dist-development/tabs.js +2 -0
  165. package/dist-development/tabs.js.map +1 -0
  166. package/dist-development/toast.js +2 -0
  167. package/dist-development/toast.js.map +1 -0
  168. package/dist-development/toggle-CIbzprg5.js +2 -0
  169. package/dist-development/toggle-CIbzprg5.js.map +1 -0
  170. package/dist-development/toolbar.js +2 -0
  171. package/dist-development/toolbar.js.map +1 -0
  172. package/dist-development/tooltip.js +2 -0
  173. package/dist-development/tooltip.js.map +1 -0
  174. package/package.json +177 -3
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ ## @bgub/fig-headless@0.1.0-alpha.0 (alpha)
2
+
3
+ ### Introduce Fig Headless as an npm alpha
4
+
5
+ Publish accessible, unstyled widget hooks and render-callback components through
6
+ individual package entries, including optional popup positioning and tabs
7
+ indicator helpers. Include production builds, development diagnostics, and
8
+ TypeScript declarations.
9
+
10
+ The first public release is `0.1.0-alpha.0` under the `alpha` tag and requires
11
+ Fig and Fig DOM 2.x. Alpha releases may change APIs and behavior. Automated
12
+ interaction and hydration tests cover the widgets; full cross-browser widget
13
+ coverage and manual screen-reader verification remain gates for stable releases.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Ben Gubler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,596 @@
1
- # Temporary Holding Version
1
+ # Fig Headless
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Accessible, unstyled widgets built around Fig host mixins. Widget roots own
4
+ state and coordination; applications keep ownership of the rendered host tree.
5
+
6
+ Status: alpha. APIs and behavior may change between alpha releases. The first
7
+ public release is `0.1.0-alpha.0`, independently versioned from Fig and published
8
+ to npm under the `alpha` tag. Fig Headless is not published to JSR.
9
+
10
+ ## Installation
11
+
12
+ ```sh
13
+ pnpm add @bgub/fig@^2.0.0 @bgub/fig-dom@^2.0.0 @bgub/fig-headless@alpha
14
+ ```
15
+
16
+ Import individual widget entries, for example `@bgub/fig-headless/tabs`.
17
+ There is no root entry. Fig 2.0 is required: the widgets use its committed host
18
+ binding APIs. Use Fig's Vite integration to select development diagnostics;
19
+ other bundlers should enable the `fig-development` export condition during
20
+ development and omit it for production.
21
+
22
+ Pin an exact alpha version when reproducible upgrades matter. This release is
23
+ for early integration feedback, not a stable accessibility or API guarantee.
24
+ Automated tests cover semantics, keyboard interaction, focus, forms, and
25
+ hydration. Selected popup and focus scenarios run in Chromium, Firefox, and
26
+ WebKit; the complete widget suite is not yet covered in all three engines.
27
+ Manual screen-reader verification has not yet been recorded. See the
28
+ [accessibility verification matrix](https://github.com/bgub/fig/blob/main/docs/concepts/widgets.md#accessibility-verification-matrix)
29
+ for coverage and the gates for a stable release.
30
+
31
+ ## Adapting The Source
32
+
33
+ Applications that need to change behavior can still copy
34
+ `packages/fig-headless/src` from a pinned Fig commit. Preserve its directory
35
+ structure and shared `internal` modules, retain the relevant tests, and replace
36
+ the package imports below with application-local imports. Copied code and its
37
+ accessibility behavior become the application's responsibility.
38
+
39
+ ## Tabs
40
+
41
+ `useTabs` returns host-part mixins for the caller's own elements:
42
+
43
+ ```tsx
44
+ import { useTabs } from "@bgub/fig-headless/tabs";
45
+
46
+ export function SettingsTabs() {
47
+ const tabs = useTabs({ defaultValue: "account" });
48
+
49
+ return (
50
+ <div>
51
+ <div aria-label="Settings" mix={tabs.list({ activateOnFocus: true })}>
52
+ <button mix={tabs.tab("account")}>Account</button>
53
+ <button mix={tabs.tab("security")}>Security</button>
54
+ </div>
55
+ <section class="panel" mix={tabs.panel("account")}>
56
+ Account settings
57
+ </section>
58
+ <section class="panel" mix={tabs.panel("security")}>
59
+ Security settings
60
+ </section>
61
+ </div>
62
+ );
63
+ }
64
+ ```
65
+
66
+ `Tabs` is the same widget as a component, for markup that is not already a
67
+ component of its own. Selection re-renders the callback's output rather than
68
+ the caller:
69
+
70
+ ```tsx
71
+ import { Tabs } from "@bgub/fig-headless/tabs";
72
+
73
+ export function SettingsTabs() {
74
+ return (
75
+ <Tabs defaultValue="account">
76
+ {(tabs) => (
77
+ <div>
78
+ <div aria-label="Settings" mix={tabs.list({ activateOnFocus: true })}>
79
+ <button mix={tabs.tab("account")}>Account</button>
80
+ <button mix={tabs.tab("security")}>Security</button>
81
+ </div>
82
+ <div class="panels">
83
+ <section class="panel" mix={tabs.panel("account")}>
84
+ Account settings
85
+ </section>
86
+ <section class="panel" mix={tabs.panel("security")}>
87
+ Security settings
88
+ </section>
89
+ </div>
90
+ </div>
91
+ )}
92
+ </Tabs>
93
+ );
94
+ }
95
+ ```
96
+
97
+ Each widget has its own package entry. There is no root barrel, namespace
98
+ object, or module side effect, so applications bundle only the entries they use.
99
+
100
+ The root can be controlled with a defined `value`, or uncontrolled with
101
+ `defaultValue`. `undefined` means the prop is absent and `null` means no active
102
+ tab. With neither prop the root selects the first enabled tab before paint.
103
+ Uncontrolled roots automatically recover when the selected tab becomes disabled
104
+ or unmounts, and report the value they settled on with a `null` event.
105
+ `onValueChange(value, details, signal)` reports user and automatic changes;
106
+ `details.cancel()` cancels user selection. Activation events retain their
107
+ native propagation; a handler on the target may use `stopPropagation()` to
108
+ suppress both the widget and its ancestors.
109
+
110
+ Manual activation is the default: arrow keys move the roving tab stop and
111
+ `Enter` or `Space` selects. Use `tabs.list({ activateOnFocus: true })` when
112
+ panels appear without latency.
113
+ Lists loop by default, support Home/End, vertical orientation, RTL, and leave
114
+ modified key chords alone. Leaving a manually activated list resets its roving
115
+ tab stop to the selected tab.
116
+
117
+ Disabled tabs remain keyboard-focusable so their labels and unavailable state
118
+ can be discovered, but they cannot activate. `tabs.panel()` keeps caller-owned
119
+ hosts mounted and hides inactive ones. For presence, branch directly on
120
+ `tabs.value`; rendering policy stays ordinary Fig state.
121
+
122
+ Control selection and wrap the state update with Fig's `useTransition` when the
123
+ application wants to animate it:
124
+
125
+ ```tsx
126
+ import { useState, useTransition, ViewTransition } from "@bgub/fig";
127
+
128
+ const [value, setValue] = useState<string | null>("account");
129
+ const [, startTransition] = useTransition();
130
+ const tabs = useTabs({
131
+ value,
132
+ onValueChange: (next) =>
133
+ startTransition(() => setValue(next), {
134
+ types: ["settings-tabs"],
135
+ viewTransition: "interrupt",
136
+ }),
137
+ });
138
+
139
+ <ViewTransition name="settings-panel">
140
+ <div class="frame">
141
+ {tabs.value === "account" ? (
142
+ <section mix={tabs.panel("account")}>Account settings</section>
143
+ ) : null}
144
+ {tabs.value === "security" ? (
145
+ <section mix={tabs.panel("security")}>Security settings</section>
146
+ ) : null}
147
+ </div>
148
+ </ViewTransition>;
149
+ ```
150
+
151
+ One boundary animates both sides: its old and new images carry the outgoing and
152
+ incoming panel, and its group interpolates the frame's height.
153
+
154
+ The images are flat snapshots that move with their animation, so a border or
155
+ background left on the frame slides away with the outgoing panel. Paint that
156
+ chrome on the group instead, and let the live frame drop it while a
157
+ `settings-tabs` transition is active:
158
+
159
+ ```css
160
+ ::view-transition-group(settings-panel) {
161
+ background: var(--card);
162
+ border: 1px solid var(--border);
163
+ box-sizing: border-box;
164
+ overflow: clip;
165
+ }
166
+
167
+ :root:active-view-transition-type(settings-tabs) .frame {
168
+ background: transparent;
169
+ border-color: transparent;
170
+ }
171
+ ```
172
+
173
+ The application owns the transition type and animation policy. It can use one
174
+ type as above or derive directional types from `details.trigger` and the
175
+ currently selected tab. Captured descendants cannot receive pointer input
176
+ during an animation, so keep the tab list outside the boundary.
177
+
178
+ The indicator remains optional, so applications that do not render one bundle
179
+ no measurement code:
180
+
181
+ ```tsx
182
+ import { useTabsIndicator } from "@bgub/fig-headless/tabs/indicator";
183
+
184
+ const indicator = useTabsIndicator();
185
+
186
+ <div mix={[tabs.list(), indicator.list()]}>
187
+ <button mix={tabs.tab("account")}>Account</button>
188
+ <button mix={tabs.tab("security")}>Security</button>
189
+ <span
190
+ mix={indicator.indicator()}
191
+ style={{
192
+ left: "var(--active-tab-left)",
193
+ width: "var(--active-tab-width)",
194
+ }}
195
+ />
196
+ </div>;
197
+ ```
198
+
199
+ It publishes `--active-tab-left`, `--active-tab-width`, and the other box
200
+ edges, and stays hidden until it has measured the active tab before paint.
201
+
202
+ ## Radio group
203
+
204
+ ```tsx
205
+ import { useRadioGroup } from "@bgub/fig-headless/radio-group";
206
+
207
+ export function SizeChoice() {
208
+ const group = useRadioGroup({ defaultValue: "medium", name: "size" });
209
+
210
+ return (
211
+ <div aria-label="Size" mix={group.root()}>
212
+ {["small", "medium", "large"].map((value) => (
213
+ <label>
214
+ <input mix={group.radio(value)} /> {value}
215
+ </label>
216
+ ))}
217
+ </div>
218
+ );
219
+ }
220
+ ```
221
+
222
+ `radio()` applies to a real `<input type="radio">`. Radios sharing a name are
223
+ already a group to the browser, so it owns the single tab stop, arrow movement
224
+ on either axis, wrapping, skipping disabled radios, Space, form submission, and
225
+ validity — the widget adds no keyboard handling at all. It names the group,
226
+ drives `checked`, and reports what the platform decided. Style the input with
227
+ `appearance: none`, or hide it and style its label.
228
+
229
+ `required` marks the group for validation, the generated `name` is on the parts
230
+ object, and your own `name` or `value` prop wins over the generated one.
231
+ `readOnly` prevents changes without disabling focus or form submission. Native
232
+ form reset restores `defaultValue` for an uncontrolled group and reasserts
233
+ `value` for a controlled group.
234
+
235
+ ## Accordion
236
+
237
+ ```tsx
238
+ import { useAccordion } from "@bgub/fig-headless/accordion";
239
+
240
+ export function Help() {
241
+ const accordion = useAccordion({ defaultValue: ["shipping"] });
242
+
243
+ return (
244
+ <div mix={accordion.root()}>
245
+ <h3>
246
+ <button mix={accordion.trigger("shipping")}>Shipping</button>
247
+ </h3>
248
+ {accordion.isOpen("shipping") ? (
249
+ <section mix={accordion.panel("shipping")}>Ships in two days.</section>
250
+ ) : null}
251
+ </div>
252
+ );
253
+ }
254
+ ```
255
+
256
+ `value` and `defaultValue` are arrays in every mode. One panel opens at a time
257
+ by default; `collapsible: false` keeps the last one open and `multiple` holds
258
+ several. Headers stay in the tab order, with optional arrow, Home, and End
259
+ movement between them. Animate the panel frame by controlling the open values:
260
+
261
+ ```tsx
262
+ const [values, setValues] = useState<readonly string[]>(["shipping"]);
263
+ const [, startTransition] = useTransition();
264
+
265
+ const accordion = useAccordion({
266
+ value: values,
267
+ onValueChange: (next) =>
268
+ startTransition(() => setValues(next), {
269
+ types: ["help-accordion"],
270
+ viewTransition: "interrupt",
271
+ }),
272
+ });
273
+ ```
274
+
275
+ The application may use separate expansion and collapse types when its visual
276
+ design needs that distinction.
277
+
278
+ ## Dialog
279
+
280
+ ```tsx
281
+ import { useDialog } from "@bgub/fig-headless/dialog";
282
+
283
+ export function DeleteFile() {
284
+ const dialog = useDialog();
285
+
286
+ return (
287
+ <>
288
+ <button mix={dialog.trigger()}>Delete</button>
289
+ <dialog mix={dialog.dialog()}>
290
+ <h2 mix={dialog.title()}>Delete this file?</h2>
291
+ <p mix={dialog.description()}>This cannot be undone.</p>
292
+ <button mix={dialog.dismiss()}>Cancel</button>
293
+ </dialog>
294
+ </>
295
+ );
296
+ }
297
+ ```
298
+
299
+ You render a real `<dialog>`, so the platform provides the top layer, focus
300
+ containment, focus restoration, the inert background, `::backdrop`, and Escape.
301
+ The widget adds open state, labelling, and dismissal policy: `closeOnEscape`
302
+ and `closeOnBackdrop` default to `true`, and a backdrop click is told apart
303
+ from a click on the dialog's own padding.
304
+
305
+ The user agent centres a modal dialog with `margin: auto`, so a reset that
306
+ zeroes margins leaves it in the corner; restore `margin: auto` on the element.
307
+
308
+ Animate it with `@starting-style` and `transition-behavior: allow-discrete`
309
+ rather than a view transition, and lock scrolling with
310
+ `html:has(dialog[open]) { overflow: clip }`, paired with `scrollbar-gutter:
311
+ stable` so hiding the scrollbar does not shift the page. All of it is CSS, so
312
+ none of it is an option on the root.
313
+
314
+ ## Popover
315
+
316
+ ```tsx
317
+ import { usePopover } from "@bgub/fig-headless/popover";
318
+
319
+ export function Filters() {
320
+ const popover = usePopover({ id: "filters-popover" });
321
+
322
+ return (
323
+ <>
324
+ <button mix={popover.trigger()}>Filters</button>
325
+ <div class="panel" mix={popover.popover()}>
326
+ ...
327
+ </div>
328
+ </>
329
+ );
330
+ }
331
+ ```
332
+
333
+ ```css
334
+ .panel {
335
+ position-area: block-end span-inline-end;
336
+ }
337
+ ```
338
+
339
+ The popover attribute provides the top layer, light dismiss, and Escape; CSS
340
+ anchor positioning provides placement. The widget measures nothing and adds no
341
+ outside-click, scroll, or resize listener — it generates one anchor name, puts
342
+ it on the trigger as `anchor-name` and on the popover as `position-anchor`, and
343
+ leaves placement to your CSS. The trigger also carries `popovertarget`, so it
344
+ works before hydration. Pass a custom popover id to the root; the root owns the
345
+ host `id` so the server-rendered trigger and popover cannot diverge.
346
+
347
+ Anchor positioning has not shipped in every browser yet, so pair your placement
348
+ with a `@supports not (anchor-name: --a)` fallback. Where the popover API
349
+ itself is missing, the widget falls back to toggling `hidden`.
350
+
351
+ ## Tooltip
352
+
353
+ ```tsx
354
+ import { useTooltip } from "@bgub/fig-headless/tooltip";
355
+
356
+ const tooltip = useTooltip({ id: "save-help" });
357
+
358
+ <>
359
+ <button mix={tooltip.trigger()}>Save</button>
360
+ <div mix={tooltip.tooltip()}>Save this document</div>
361
+ </>;
362
+ ```
363
+
364
+ Keyboard focus opens immediately, mouse hover uses a configurable delay, and
365
+ Escape closes. Existing `aria-describedby` references are preserved. Tooltip
366
+ content is descriptive and non-interactive; use a popover for controls.
367
+
368
+ ## Listbox
369
+
370
+ ```tsx
371
+ import { useListbox } from "@bgub/fig-headless/listbox";
372
+
373
+ const listbox = useListbox({ defaultValue: ["apple"], multiple: true });
374
+
375
+ <div aria-label="Fruit" mix={listbox.root()}>
376
+ <div mix={listbox.option("apple")}>Apple</div>
377
+ <div mix={listbox.option("banana")}>Banana</div>
378
+ </div>;
379
+ ```
380
+
381
+ Selection is array-shaped in single and multiple modes. DOM focus stays on the
382
+ root while arrows, Home/End, and typeahead move an active descendant. Movement
383
+ selects in single mode; Enter and Space toggle in multiple mode. The
384
+ application owns scrolling, virtualization, and form serialization, so an
385
+ unmounted option does not implicitly erase its selected value.
386
+
387
+ ## Select
388
+
389
+ Prefer native `<select>` when its rendering works. The Fig Headless select covers
390
+ the custom-popup case while leaving every host and label to the application:
391
+
392
+ ```tsx
393
+ import { useSelect } from "@bgub/fig-headless/select";
394
+
395
+ const select = useSelect({ defaultValue: "apple", name: "fruit" });
396
+
397
+ <>
398
+ <button mix={select.trigger()}>{select.value}</button>
399
+ <div mix={select.popup()}>
400
+ <div mix={select.option("apple")}>Apple</div>
401
+ <div mix={select.option("banana")}>Banana</div>
402
+ </div>
403
+ <input mix={select.hiddenInput()} />
404
+ </>;
405
+ ```
406
+
407
+ It provides active-descendant navigation, disabled-option skipping, typeahead,
408
+ controlled or uncontrolled selection, read-only inspection, form submission,
409
+ and reset. Native constraint validation remains a reason to use `<select>`.
410
+
411
+ ## Combobox
412
+
413
+ ```tsx
414
+ import { useCombobox } from "@bgub/fig-headless/combobox";
415
+
416
+ const combobox = useCombobox({ name: "fruit" });
417
+ const matches = fruits.filter((fruit) =>
418
+ fruit.startsWith(combobox.inputValue.toLowerCase()),
419
+ );
420
+
421
+ <>
422
+ <input mix={combobox.input()} />
423
+ <div mix={combobox.popup()}>
424
+ {matches.map((fruit) => (
425
+ <div mix={combobox.option(fruit)}>{fruit}</div>
426
+ ))}
427
+ </div>
428
+ <input mix={combobox.hiddenInput()} />
429
+ </>;
430
+ ```
431
+
432
+ Fig Headless owns input/listbox relationships, active-descendant movement,
433
+ selection, controlled state, and form reset. The application owns filtering,
434
+ async loading, option rendering, empty states, and placement CSS.
435
+
436
+ ## Menu
437
+
438
+ ```tsx
439
+ import { useMenu } from "@bgub/fig-headless/menu";
440
+
441
+ export function Actions() {
442
+ const menu = useMenu({ onSelect: (value) => run(value) });
443
+
444
+ return (
445
+ <>
446
+ <button mix={menu.trigger()}>Actions</button>
447
+ <div class="menu" mix={menu.menu()}>
448
+ <button mix={menu.item("rename")}>Rename</button>
449
+ <button mix={menu.item("remove")}>Remove</button>
450
+ </div>
451
+ </>
452
+ );
453
+ }
454
+ ```
455
+
456
+ The menu is built on the popover, so it inherits the top layer, light dismiss,
457
+ Escape, and anchor positioning, and adds what a menu needs: roles, arrow and
458
+ Home/End movement, typeahead, and focus. `showPopover()` leaves focus alone, so
459
+ the menu moves it to the first item on open — the last with ArrowUp — and
460
+ returns it to the trigger on close. Tab closes rather than walking the items.
461
+ From a submenu, Tab closes the whole menu tree.
462
+
463
+ `onSelect` runs before the menu closes, and `details.cancel()` keeps it open.
464
+ Action items close by default and can set `closeOnSelect: false`.
465
+ `checkboxItem(value, { checked })` and `radioItem(value, { checked })` expose
466
+ controlled checked menu items and stay open by default.
467
+
468
+ Submenus are optional:
469
+
470
+ ```tsx
471
+ import { useMenuSubmenu } from "@bgub/fig-headless/menu/submenu";
472
+
473
+ const share = useMenuSubmenu(menu, "share");
474
+
475
+ <div mix={menu.menu()}>
476
+ <button mix={share.trigger()}>Share</button>
477
+ <div mix={share.menu()}>
478
+ <button mix={share.item("email")}>Email</button>
479
+ </div>
480
+ </div>;
481
+ ```
482
+
483
+ Inline-end opens, inline-start closes, RTL reverses both, and hover uses a
484
+ configurable delay. Focus enters the child on keyboard open and returns to the
485
+ parent trigger on close. An accepted child action closes the whole menu tree.
486
+
487
+ ## Toolbar
488
+
489
+ ```tsx
490
+ import { useToolbar } from "@bgub/fig-headless/toolbar";
491
+
492
+ const toolbar = useToolbar();
493
+
494
+ <div aria-label="Formatting" mix={toolbar.root()}>
495
+ <button mix={toolbar.item("bold")}>Bold</button>
496
+ <button mix={toolbar.item("italic")}>Italic</button>
497
+ </div>;
498
+ ```
499
+
500
+ Toolbar provides one roving tab stop, orientation-aware and RTL-aware arrow
501
+ movement, Home/End, disabled-item skipping, and optional focus looping. It owns
502
+ no command or selection state; every item remains the application's control.
503
+
504
+ ## Toast region
505
+
506
+ ```tsx
507
+ import { useToastRegion } from "@bgub/fig-headless/toast";
508
+
509
+ const region = useToastRegion({
510
+ onDismiss: (id) =>
511
+ setToasts((items) => items.filter((item) => item.id !== id)),
512
+ });
513
+
514
+ <div mix={region.region()}>
515
+ {toasts.map((toast) => (
516
+ <div mix={region.toast(toast.id)}>
517
+ {toast.message}
518
+ <button mix={region.dismiss(toast.id)}>Dismiss</button>
519
+ </div>
520
+ ))}
521
+ </div>;
522
+ ```
523
+
524
+ The application owns the toast array, markup, placement, and stacking. Keep the
525
+ region mounted before adding notifications; it supplies polite or assertive
526
+ live semantics, dismiss requests, and lifetimes that pause for focus, hover,
527
+ and a hidden document. Urgency is region-scoped, so use separately labelled
528
+ regions when an application genuinely needs both. There is no provider, global
529
+ queue, portal, or gesture policy.
530
+
531
+ ## Checkbox, switch, and field
532
+
533
+ ```tsx
534
+ import { useCheckbox } from "@bgub/fig-headless/checkbox";
535
+ import { useField } from "@bgub/fig-headless/field";
536
+
537
+ export function Terms({ invalid }: { invalid: boolean }) {
538
+ const field = useField({ invalid, required: true });
539
+ const checkbox = useCheckbox({ name: "terms", value: "accepted" });
540
+
541
+ return (
542
+ <div>
543
+ <label mix={field.label()}>Accept the terms</label>
544
+ <input mix={[field.control(), checkbox.control()]} />
545
+ {invalid ? <p mix={field.error()}>This is required.</p> : null}
546
+ </div>
547
+ );
548
+ }
549
+ ```
550
+
551
+ Both the checkbox and the switch apply to a native `<input type="checkbox">`,
552
+ so the browser owns toggling, focus, Space, submission, and validity. The
553
+ switch is the same control with `role="switch"`. The one thing the widget must
554
+ write itself is `indeterminate`, which the platform exposes only as a property.
555
+ Both controls accept `readOnly`, which blocks toggling without removing focus
556
+ or form submission. Form reset restores their initial `defaultChecked` state.
557
+
558
+ `useField` preserves authored `aria-describedby` references and then wires all
559
+ mounted descriptions followed by active errors. Pass stable keys to
560
+ `description(key)` and `error(key)` when rendering repeated messages; errors
561
+ are referenced only while `invalid` is true.
562
+
563
+ ## Inline lists, commands, context menus, and sliders
564
+
565
+ - `useCombobox({ inline: true })` renders a list in document flow; `open: true`
566
+ keeps it visible. Escape remains available to an enclosing dialog.
567
+ - `useCommand({ items, onAction })` in `command/command.tsx` provides a searchable
568
+ action list with explicit `{ value, label, keywords?, group?, disabled? }`
569
+ items. Render its filtered `items` through `input()`, `list()`, and `item(item)`;
570
+ `group(label)` and `emptyMessage()` add grouping and empty-result semantics.
571
+ - `useContextMenu()` in `menu/context-menu.tsx` provides Menu parts whose
572
+ `trigger()` opens on right-click, ContextMenu, or Shift+F10. The optional module
573
+ handles pointer placement, keyboard placement, viewport constraints, and RTL.
574
+ - `useSlider({ defaultValue: 0.5, min: 0, max: 1, step: 0.1 })` in
575
+ `slider/slider.tsx` exposes a native range `control()`. Values are single
576
+ numbers; `onValueChange` requests updates and `onValueCommit` reports native
577
+ commit events. Label the input with a native label or ARIA.
578
+ - A Dialog can render its content conditionally on `dialog.open`; its title must
579
+ be present when it opens. Closed shells can defer mounting their contents.
580
+
581
+ Each hook also has the corresponding render-callback component. See the
582
+ [widget contracts](../../docs/concepts/widgets.md) for complete behavior,
583
+ composition examples, and reference comparisons.
584
+
585
+ ## Development diagnostics
586
+
587
+ Development builds reject markup that would silently discard a widget's
588
+ accessibility contract. Checkbox, switch, and radio parts require native
589
+ `<input>` elements; accordion, dialog, popover, and menu triggers require
590
+ native `<button>` elements, as does a toast dismiss control; `dialog()` requires
591
+ `<dialog>`; and `field.label()` requires `<label>`. Tab lists, radio groups,
592
+ listboxes, and toast regions require an accessible name, dialogs and field
593
+ controls require their naming parts or an explicit ARIA name, singleton parts,
594
+ item, panel, and toast values, and field message ids must be unique, and every
595
+ mounted panel must have a matching control. These checks are removed from
596
+ production bundles.
@@ -0,0 +1,46 @@
1
+ import { t as ChangeDetails } from "./changes--uvVfvy8.js";
2
+ import { t as Orientation } from "./composite-CG4JghHi.js";
3
+ import { FigNode, MixinDescriptor } from "@bgub/fig";
4
+ //#region src/accordion/accordion.d.ts
5
+ type AccordionOrientation = Orientation;
6
+ type AccordionValueChangeDetails = ChangeDetails;
7
+ type AccordionValueChangeHandler<Value = unknown> = (values: readonly Value[], details: AccordionValueChangeDetails, signal: AbortSignal) => void;
8
+ interface AccordionTriggerOptions {
9
+ disabled?: boolean;
10
+ }
11
+ interface AccordionOptions<Value = unknown> {
12
+ /** Allows closing the last open panel in single mode. Defaults to `true`. */
13
+ collapsible?: boolean;
14
+ defaultValue?: readonly Value[];
15
+ /** Disables every trigger. */
16
+ disabled?: boolean;
17
+ /** Keeps more than one panel open at a time. */
18
+ multiple?: boolean;
19
+ onValueChange?: AccordionValueChangeHandler<Value>;
20
+ orientation?: AccordionOrientation;
21
+ value?: readonly Value[];
22
+ }
23
+ interface AccordionParts<Value = unknown> {
24
+ readonly values: readonly Value[];
25
+ isOpen(value: Value): boolean;
26
+ /** Marks a panel the caller keeps mounted; closed panels hide. */
27
+ panel(value: Value): MixinDescriptor;
28
+ root(): MixinDescriptor;
29
+ trigger(value: Value, options?: AccordionTriggerOptions): MixinDescriptor;
30
+ }
31
+ interface AccordionProps<Value = unknown> extends AccordionOptions<Value> {
32
+ children: (accordion: AccordionParts<Value>) => FigNode;
33
+ }
34
+ /**
35
+ * Coordinates one accordion: which panels are open, the relationships between
36
+ * headers and regions, and optional arrow movement between headers. Call it in
37
+ * the component that renders the accordion; every element stays the caller's.
38
+ */
39
+ declare function useAccordion<Value = unknown>(options?: AccordionOptions<Value>): AccordionParts<Value>;
40
+ /**
41
+ * {@link useAccordion} as a component, for an accordion that is not already a
42
+ * component of its own. Toggling re-renders this root rather than the caller.
43
+ */
44
+ declare function Accordion<Value = unknown>(props: AccordionProps<Value>): FigNode;
45
+ //#endregion
46
+ export { Accordion, AccordionOptions, AccordionOrientation, AccordionParts, AccordionProps, AccordionTriggerOptions, AccordionValueChangeDetails, AccordionValueChangeHandler, useAccordion };
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./changes-DglRuvQa.js";import{a as t,i as n,n as r,t as i}from"./parts-I0jdefyp.js";import"./diagnostics-CoURcwqb.js";import{i as a,t as o}from"./composite-CiKLX-Ti.js";import{n as s}from"./controllable-value-CDbRzlId.js";import{t as c}from"./ids-Cvu36mFL.js";import{t as l}from"./relations-uMHtgW03.js";import{createMixin as u,useBeforePaint as d,useMemo as f,useStableEvent as p}from"@bgub/fig";import{on as m}from"@bgub/fig-dom";const h=u((e,t)=>({bind:r(e,t.registry,(e,n)=>t.registry.bindContainer(e,n)),"data-disabled":t.disabled?``:void 0,"data-fig-accordion":``,"data-orientation":t.orientation,mix:[m(`click`,i(t.registry,(e,n)=>t.toggle(e.value,n,e.node))),m(`keydown`,e=>{t.registry.itemAt(e.target)!==void 0&&t.registry.moveFocus(e,{loop:!1,orientation:t.orientation})})]})),g=u((e,t,i)=>{let a=i.disabled||e.props.disabled===!0;return{...n(e,{disabled:a,id:i.triggerId}),"aria-controls":i.panelId,"aria-expanded":i.open?`true`:`false`,bind:r(e,t.registry,(e,n)=>t.registry.bindItem(e,n,i.value,a)),"data-fig-accordion-trigger":``,"data-open":i.open?``:void 0}}),_=u((e,t,n)=>({"aria-labelledby":n.triggerId,bind:r(e,t.registry,(e,r)=>t.relations.bindPanel(e,r,n.value)),"data-open":n.open?``:void 0,hidden:!n.open||void 0,id:e.props.id??n.panelId,role:`region`}));function v(n={}){let{collapsible:r=!0,disabled:i=!1,multiple:u=!1,orientation:m=`vertical`}=n,v=t(),y=s({value:n.value,defaultValue:n.defaultValue??[],onChange:n.onValueChange,reconcile:v}),b=y.value,x=f(()=>o({container:`[data-fig-accordion]`,item:`[data-fig-accordion-trigger]`,name:`accordion`,registrationChanged:v}),[]),S=f(()=>l(x,v),[]);d(()=>{S.sync()});let C=p((t,n,i)=>{y.request(e=>{let n=e.some(e=>a(e,t));return n&&!r&&!u?e:n?e.filter(e=>!a(e,t)):u?[...e,t]:[t]},e(n,i))}),w=c(),T={disabled:i,orientation:m,registry:x,relations:S,toggle:C};function E(e){return b.some(t=>a(t,e))}function D(e){return _(T,{open:E(e),panelId:w(e,`panel`),triggerId:w(e,`trigger`),value:e})}return{isOpen:E,panel:D,root:()=>h(T),trigger:(e,t={})=>g(T,{disabled:i||t.disabled===!0,open:E(e),panelId:w(e,`panel`),triggerId:w(e,`trigger`),value:e}),values:b}}function y(e){return e.children(v(e))}export{y as Accordion,v as useAccordion};
2
+ //# sourceMappingURL=accordion.js.map