@vertz/ui-primitives 0.1.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.
package/README.md ADDED
@@ -0,0 +1,541 @@
1
+ # @vertz/primitives
2
+
3
+ Headless, WAI-ARIA compliant UI primitives for Vertz. Provides pre-built, accessible components with keyboard handling, focus management, and reactive state — you bring the styles.
4
+
5
+ ## What it does
6
+
7
+ `@vertz/primitives` offers low-level UI components that handle:
8
+
9
+ - **Accessibility** — Proper ARIA roles, attributes, and live regions
10
+ - **Keyboard interaction** — Enter/Space for activation, Arrow keys for navigation, Escape for closing
11
+ - **Focus management** — Tab order, focus trapping, roving tabindex
12
+ - **State management** — Reactive state via `@vertz/ui` signals
13
+
14
+ These primitives are **intentionally imperative** — they return pre-wired DOM elements that you compose with your JSX. This gives you full control over styling and layout while ensuring accessibility best practices.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @vertz/primitives
20
+ ```
21
+
22
+ This package depends on `@vertz/ui` for reactive state.
23
+
24
+ ## Available Primitives
25
+
26
+ | Primitive | Description |
27
+ |-----------|-------------|
28
+ | **Accordion** | Collapsible sections with single or multiple expansion |
29
+ | **Button** | Accessible button with press state |
30
+ | **Checkbox** | Checkbox with indeterminate state support |
31
+ | **Combobox** | Searchable dropdown with filtering |
32
+ | **Dialog** | Modal dialog with focus trap and backdrop |
33
+ | **Menu** | Dropdown menu with arrow key navigation |
34
+ | **Popover** | Floating content anchored to a trigger |
35
+ | **Progress** | Progress bar with indeterminate mode |
36
+ | **Radio** | Radio button group with arrow key navigation |
37
+ | **Select** | Single-select dropdown |
38
+ | **Slider** | Range input with thumb and track |
39
+ | **Switch** | Toggle switch (on/off) |
40
+ | **Tabs** | Tabbed interface with keyboard navigation |
41
+ | **Toast** | Notification toast with auto-dismiss |
42
+ | **Tooltip** | Hover tooltip with pointer positioning |
43
+
44
+ ## Usage
45
+
46
+ ### Basic Pattern
47
+
48
+ All primitives follow this structure:
49
+
50
+ ```typescript
51
+ import { Button } from '@vertz/primitives';
52
+
53
+ // Create the primitive
54
+ const { root, state } = Button.Root({
55
+ disabled: false,
56
+ onPress: () => console.log('Pressed!'),
57
+ });
58
+
59
+ // Customize the element
60
+ root.textContent = 'Click Me';
61
+ root.classList.add('my-button-class');
62
+
63
+ // Use in JSX or append to DOM
64
+ document.body.appendChild(root);
65
+ ```
66
+
67
+ **Key concepts:**
68
+
69
+ - `Root()` returns an object with DOM elements and reactive state
70
+ - Elements have ARIA attributes and event handlers pre-configured
71
+ - `state` contains reactive signals you can read or modify
72
+ - You control styling, layout, and composition
73
+
74
+ ### Button
75
+
76
+ ```tsx
77
+ import { Button } from '@vertz/primitives';
78
+ import { css } from '@vertz/ui/css';
79
+
80
+ const styles = css({
81
+ btn: ['px:4', 'py:2', 'bg:blue.600', 'text:white', 'rounded:md'],
82
+ disabled: ['opacity:50', 'cursor:not-allowed'],
83
+ });
84
+
85
+ function MyButton() {
86
+ const { root, state } = Button.Root({
87
+ disabled: false,
88
+ onPress: () => alert('Button pressed!'),
89
+ });
90
+
91
+ root.textContent = 'Submit';
92
+ root.classList.add(styles.classNames.btn);
93
+
94
+ // React to state changes
95
+ effect(() => {
96
+ if (state.disabled.value) {
97
+ root.classList.add(styles.classNames.disabled);
98
+ }
99
+ });
100
+
101
+ return root;
102
+ }
103
+ ```
104
+
105
+ **Button API:**
106
+
107
+ ```typescript
108
+ Button.Root(options: ButtonOptions): {
109
+ root: HTMLButtonElement;
110
+ state: {
111
+ disabled: Signal<boolean>;
112
+ pressed: Signal<boolean>;
113
+ };
114
+ }
115
+ ```
116
+
117
+ ### Checkbox
118
+
119
+ ```tsx
120
+ import { Checkbox } from '@vertz/primitives';
121
+
122
+ function TodoItem() {
123
+ let isDone = false;
124
+
125
+ const { root, state } = Checkbox.Root({
126
+ checked: isDone,
127
+ onCheckedChange: (checked) => {
128
+ isDone = checked === true;
129
+ },
130
+ });
131
+
132
+ const label = document.createElement('label');
133
+ label.textContent = 'Complete task';
134
+ label.prepend(root);
135
+
136
+ return label;
137
+ }
138
+ ```
139
+
140
+ **Checkbox API:**
141
+
142
+ ```typescript
143
+ Checkbox.Root(options: CheckboxOptions): {
144
+ root: HTMLInputElement;
145
+ state: {
146
+ checked: Signal<CheckedState>; // true | false | 'indeterminate'
147
+ disabled: Signal<boolean>;
148
+ };
149
+ }
150
+ ```
151
+
152
+ ### Dialog
153
+
154
+ ```tsx
155
+ import { Dialog } from '@vertz/primitives';
156
+
157
+ function LoginDialog() {
158
+ const { overlay, content, state } = Dialog.Root({
159
+ open: false,
160
+ onOpenChange: (open) => console.log(`Dialog ${open ? 'opened' : 'closed'}`),
161
+ });
162
+
163
+ content.innerHTML = `
164
+ <h2>Log In</h2>
165
+ <input type="email" placeholder="Email" />
166
+ <input type="password" placeholder="Password" />
167
+ <button>Submit</button>
168
+ `;
169
+
170
+ const trigger = document.createElement('button');
171
+ trigger.textContent = 'Open Dialog';
172
+ trigger.onclick = () => state.open.value = true;
173
+
174
+ const container = document.createElement('div');
175
+ container.appendChild(trigger);
176
+ container.appendChild(overlay);
177
+
178
+ return container;
179
+ }
180
+ ```
181
+
182
+ **Dialog API:**
183
+
184
+ ```typescript
185
+ Dialog.Root(options: DialogOptions): {
186
+ overlay: HTMLDivElement; // Backdrop element
187
+ content: HTMLDivElement; // Dialog content (focus trapped)
188
+ state: {
189
+ open: Signal<boolean>;
190
+ };
191
+ }
192
+ ```
193
+
194
+ The dialog handles:
195
+ - Focus trapping (Tab cycles through focusable elements)
196
+ - Escape key to close
197
+ - Click outside overlay to close
198
+ - Backdrop element with `aria-hidden` when open
199
+
200
+ ### Menu
201
+
202
+ ```tsx
203
+ import { Menu } from '@vertz/primitives';
204
+
205
+ function Dropdown() {
206
+ const { trigger, content, state } = Menu.Root({
207
+ open: false,
208
+ onOpenChange: (open) => console.log(`Menu ${open ? 'opened' : 'closed'}`),
209
+ });
210
+
211
+ trigger.textContent = 'Actions';
212
+
213
+ const item1 = Menu.Item({
214
+ onSelect: () => console.log('Edit clicked'),
215
+ });
216
+ item1.textContent = 'Edit';
217
+
218
+ const item2 = Menu.Item({
219
+ onSelect: () => console.log('Delete clicked'),
220
+ });
221
+ item2.textContent = 'Delete';
222
+
223
+ content.append(item1, item2);
224
+
225
+ const container = document.createElement('div');
226
+ container.append(trigger, content);
227
+ return container;
228
+ }
229
+ ```
230
+
231
+ **Menu API:**
232
+
233
+ ```typescript
234
+ Menu.Root(options: MenuOptions): {
235
+ trigger: HTMLButtonElement;
236
+ content: HTMLDivElement;
237
+ state: {
238
+ open: Signal<boolean>;
239
+ };
240
+ }
241
+
242
+ Menu.Item(options: { onSelect?: () => void }): HTMLDivElement
243
+ ```
244
+
245
+ Menu handles:
246
+ - Arrow Up/Down navigation
247
+ - Enter/Space to select
248
+ - Escape to close
249
+ - Auto-focus first item on open
250
+
251
+ ### Select
252
+
253
+ ```tsx
254
+ import { Select } from '@vertz/primitives';
255
+
256
+ function LanguageSelect() {
257
+ const { trigger, content, state } = Select.Root({
258
+ value: 'en',
259
+ onValueChange: (value) => console.log(`Selected: ${value}`),
260
+ });
261
+
262
+ const options = [
263
+ { value: 'en', label: 'English' },
264
+ { value: 'es', label: 'Spanish' },
265
+ { value: 'fr', label: 'French' },
266
+ ];
267
+
268
+ for (const opt of options) {
269
+ const option = Select.Option({ value: opt.value });
270
+ option.textContent = opt.label;
271
+ content.appendChild(option);
272
+ }
273
+
274
+ return <div>{trigger}{content}</div>;
275
+ }
276
+ ```
277
+
278
+ **Select API:**
279
+
280
+ ```typescript
281
+ Select.Root(options: SelectOptions): {
282
+ trigger: HTMLButtonElement;
283
+ content: HTMLDivElement;
284
+ state: {
285
+ value: Signal<string>;
286
+ open: Signal<boolean>;
287
+ };
288
+ }
289
+
290
+ Select.Option(options: { value: string }): HTMLDivElement
291
+ ```
292
+
293
+ ### Tabs
294
+
295
+ ```tsx
296
+ import { Tabs } from '@vertz/primitives';
297
+
298
+ function Settings() {
299
+ const { list, state } = Tabs.Root({
300
+ defaultValue: 'general',
301
+ onValueChange: (value) => console.log(`Tab: ${value}`),
302
+ });
303
+
304
+ const tab1 = Tabs.Trigger({ value: 'general' });
305
+ tab1.textContent = 'General';
306
+
307
+ const tab2 = Tabs.Trigger({ value: 'privacy' });
308
+ tab2.textContent = 'Privacy';
309
+
310
+ list.append(tab1, tab2);
311
+
312
+ const panel1 = Tabs.Content({ value: 'general' });
313
+ panel1.textContent = 'General settings...';
314
+
315
+ const panel2 = Tabs.Content({ value: 'privacy' });
316
+ panel2.textContent = 'Privacy settings...';
317
+
318
+ return (
319
+ <div>
320
+ {list}
321
+ {panel1}
322
+ {panel2}
323
+ </div>
324
+ );
325
+ }
326
+ ```
327
+
328
+ **Tabs API:**
329
+
330
+ ```typescript
331
+ Tabs.Root(options: TabsOptions): {
332
+ list: HTMLDivElement;
333
+ state: {
334
+ value: Signal<string>;
335
+ };
336
+ }
337
+
338
+ Tabs.Trigger(options: { value: string }): HTMLButtonElement
339
+ Tabs.Content(options: { value: string }): HTMLDivElement
340
+ ```
341
+
342
+ Tabs handle:
343
+ - Arrow Left/Right navigation
344
+ - Home/End keys to jump to first/last tab
345
+ - Automatic panel switching
346
+ - Proper `aria-selected` and `aria-controls` attributes
347
+
348
+ ## Advanced: Custom Primitives
349
+
350
+ If you need to build your own headless components, use the utilities from `@vertz/primitives/utils`:
351
+
352
+ ```typescript
353
+ import {
354
+ uniqueId,
355
+ setLabelledBy,
356
+ setDescribedBy,
357
+ getFocusableElements,
358
+ trapFocus,
359
+ handleListNavigation,
360
+ Keys,
361
+ } from '@vertz/primitives/utils';
362
+
363
+ function CustomCombobox() {
364
+ const inputId = uniqueId();
365
+ const listboxId = uniqueId();
366
+
367
+ const input = document.createElement('input');
368
+ input.id = inputId;
369
+ input.setAttribute('role', 'combobox');
370
+ input.setAttribute('aria-controls', listboxId);
371
+
372
+ const listbox = document.createElement('ul');
373
+ listbox.id = listboxId;
374
+ listbox.setAttribute('role', 'listbox');
375
+ setLabelledBy(listbox, inputId);
376
+
377
+ input.addEventListener('keydown', (e) => {
378
+ if (Keys.isArrowDown(e)) {
379
+ const focusable = getFocusableElements(listbox);
380
+ focusable[0]?.focus();
381
+ }
382
+ });
383
+
384
+ return { input, listbox };
385
+ }
386
+ ```
387
+
388
+ ### Available Utilities
389
+
390
+ **ARIA Helpers:**
391
+
392
+ - `setLabelledBy(el, id)` — Set `aria-labelledby`
393
+ - `setDescribedBy(el, id)` — Set `aria-describedby`
394
+ - `setControls(el, id)` — Set `aria-controls`
395
+ - `setExpanded(el, expanded)` — Set `aria-expanded`
396
+ - `setSelected(el, selected)` — Set `aria-selected`
397
+ - `setChecked(el, checked)` — Set `aria-checked`
398
+ - `setDisabled(el, disabled)` — Set `aria-disabled` and `disabled` attribute
399
+ - `setHidden(el, hidden)` — Set `aria-hidden`
400
+ - `setDataState(el, state)` — Set `data-state` attribute
401
+ - `setValueRange(el, value, min, max)` — Set `aria-valuenow/min/max`
402
+ - `toggleExpanded(el)` — Toggle `aria-expanded`
403
+
404
+ **Focus Management:**
405
+
406
+ - `getFocusableElements(container)` — Get all focusable elements in a container
407
+ - `focusFirst(container)` — Focus the first focusable element
408
+ - `saveFocus()` — Save the currently focused element (returns restore function)
409
+ - `trapFocus(container)` — Trap focus within a container (returns cleanup function)
410
+ - `setRovingTabindex(elements, activeIndex)` — Set roving tabindex pattern
411
+
412
+ **ID Generation:**
413
+
414
+ - `uniqueId(prefix?)` — Generate unique ID (e.g., `vertz-1`, `vertz-2`)
415
+ - `linkedIds(prefix, count)` — Generate multiple related IDs
416
+ - `resetIdCounter()` — Reset counter (useful for testing)
417
+
418
+ **Keyboard Handling:**
419
+
420
+ - `handleActivation(event, callback)` — Call callback on Enter/Space
421
+ - `handleListNavigation(event, items, currentIndex, onChange)` — Arrow key navigation
422
+ - `Keys` — Key name constants (`Keys.Enter`, `Keys.Escape`, `Keys.ArrowUp`, etc.)
423
+ - `isKey(event, key)` — Check if event matches key
424
+
425
+ ## State Management
426
+
427
+ All primitives use `@vertz/ui` signals for state. You can read and modify state imperatively:
428
+
429
+ ```typescript
430
+ const { state } = Dialog.Root({ open: false });
431
+
432
+ // Read
433
+ console.log(state.open.value); // false
434
+
435
+ // Write
436
+ state.open.value = true;
437
+
438
+ // React
439
+ effect(() => {
440
+ console.log(`Dialog is ${state.open.value ? 'open' : 'closed'}`);
441
+ });
442
+ ```
443
+
444
+ State properties are read-only at the type level but writable at runtime (for internal use). Prefer using the component's callbacks (`onOpenChange`, `onValueChange`, etc.) for external state changes.
445
+
446
+ ## Styling
447
+
448
+ Primitives provide **zero styles**. You can style them with:
449
+
450
+ - **CSS classes** — `root.classList.add('my-button')`
451
+ - **Inline styles** — `root.style.padding = '8px'`
452
+ - **@vertz/ui/css** — `root.classList.add(css({ btn: [...] }).classNames.btn)`
453
+ - **Tailwind/UnoCSS** — `root.className = 'px-4 py-2 bg-blue-500'`
454
+
455
+ **Data attributes for styling:**
456
+
457
+ Most primitives set `data-state` attributes you can use for CSS:
458
+
459
+ ```css
460
+ button[data-state="idle"] { /* ... */ }
461
+ button[data-state="pressed"] { /* ... */ }
462
+ button[data-state="disabled"] { /* ... */ }
463
+
464
+ [aria-expanded="true"] { /* ... */ }
465
+ [aria-checked="true"] { /* ... */ }
466
+ ```
467
+
468
+ ## Accessibility
469
+
470
+ All primitives follow WAI-ARIA authoring practices:
471
+
472
+ - **Buttons** — `role="button"`, Enter/Space activation
473
+ - **Checkboxes** — Native `<input type="checkbox">` with `aria-checked`
474
+ - **Dialogs** — Focus trap, Escape to close, `aria-modal`, `aria-labelledby`
475
+ - **Menus** — Arrow key navigation, `role="menu"`, `role="menuitem"`
476
+ - **Tabs** — Arrow navigation, `aria-selected`, `aria-controls`, `role="tab"` / `role="tabpanel"`
477
+ - **Sliders** — `aria-valuenow/min/max`, Arrow keys for adjustment
478
+ - **Comboboxes** — `aria-autocomplete`, `aria-expanded`, `aria-controls`
479
+
480
+ Keyboard navigation is always included where applicable.
481
+
482
+ ## Type Definitions
483
+
484
+ Each primitive exports:
485
+
486
+ - **`[Component].Root(options)`** — Creates the primitive with options
487
+ - **`[Component]Options`** — Options interface
488
+ - **`[Component]State`** — Reactive state interface
489
+ - **`[Component]Elements`** — DOM element references
490
+
491
+ Example:
492
+
493
+ ```typescript
494
+ import type { ButtonOptions, ButtonState, ButtonElements } from '@vertz/primitives';
495
+
496
+ const options: ButtonOptions = {
497
+ disabled: false,
498
+ onPress: () => {},
499
+ };
500
+
501
+ const button: ButtonElements & { state: ButtonState } = Button.Root(options);
502
+ ```
503
+
504
+ ## Performance
505
+
506
+ Primitives are **lightweight**:
507
+
508
+ - No virtual DOM
509
+ - No framework overhead
510
+ - Direct DOM manipulation
511
+ - Fine-grained reactivity via signals
512
+ - Event delegation where applicable
513
+
514
+ A typical primitive (e.g., `Button`) is ~2KB gzipped including reactivity.
515
+
516
+ ## Relationship to @vertz/ui
517
+
518
+ `@vertz/primitives` builds on top of `@vertz/ui`:
519
+
520
+ - Uses `signal()` and `effect()` for reactive state
521
+ - Compatible with Vertz's JSX and compiler
522
+ - Can be composed with `@vertz/ui` components
523
+
524
+ However, primitives are **intentionally imperative** — they return DOM elements rather than JSX components. This gives you full control over composition and styling while maintaining accessibility.
525
+
526
+ ## Related Packages
527
+
528
+ - **[@vertz/ui](../ui)** — The main UI framework (JSX, reactivity, compiler)
529
+ - **[@vertz/ui-compiler](../ui-compiler)** — Vite plugin for compiling Vertz components
530
+
531
+ ## Inspiration
532
+
533
+ This package is inspired by:
534
+
535
+ - [Radix UI](https://www.radix-ui.com/) — Unstyled, accessible components
536
+ - [Headless UI](https://headlessui.com/) — Unstyled component behaviors
537
+ - [Ark UI](https://ark-ui.com/) — Framework-agnostic UI primitives
538
+
539
+ ## License
540
+
541
+ MIT