orio-ui 1.27.0 → 1.28.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 (64) hide show
  1. package/README.md +76 -1
  2. package/bin/orio-ui.mjs +72 -0
  3. package/dist/agents/ROUTING.md +140 -0
  4. package/dist/agents/component-finder.md +142 -0
  5. package/dist/agents/component-worker.md +152 -0
  6. package/dist/agents/snippet.md +6 -0
  7. package/dist/module.json +1 -1
  8. package/dist/runtime/components/AnimatedContainer.USAGE.md +79 -0
  9. package/dist/runtime/components/Badge.USAGE.md +75 -0
  10. package/dist/runtime/components/Banner.USAGE.md +52 -0
  11. package/dist/runtime/components/Button.USAGE.md +78 -0
  12. package/dist/runtime/components/Calendar.USAGE.md +8 -0
  13. package/dist/runtime/components/Canvas/USAGE.md +8 -0
  14. package/dist/runtime/components/CheckBox.USAGE.md +63 -0
  15. package/dist/runtime/components/CheckboxGroup.USAGE.md +95 -0
  16. package/dist/runtime/components/ControlElement.USAGE.md +8 -0
  17. package/dist/runtime/components/DashedContainer.USAGE.md +65 -0
  18. package/dist/runtime/components/EmptyState.USAGE.md +65 -0
  19. package/dist/runtime/components/Form.USAGE.md +102 -0
  20. package/dist/runtime/components/Icon.USAGE.md +61 -0
  21. package/dist/runtime/components/Input.USAGE.md +8 -0
  22. package/dist/runtime/components/ListItem.USAGE.md +84 -0
  23. package/dist/runtime/components/LoadingSpinner.USAGE.md +50 -0
  24. package/dist/runtime/components/LocaleSwitcher.USAGE.md +73 -0
  25. package/dist/runtime/components/Modal.USAGE.md +8 -0
  26. package/dist/runtime/components/NavButton.USAGE.md +80 -0
  27. package/dist/runtime/components/NumberInput/Horizontal.USAGE.md +61 -0
  28. package/dist/runtime/components/NumberInput/USAGE.md +74 -0
  29. package/dist/runtime/components/NumberInput/Vertical.USAGE.md +55 -0
  30. package/dist/runtime/components/Popover.USAGE.md +103 -0
  31. package/dist/runtime/components/RadioButton.USAGE.md +72 -0
  32. package/dist/runtime/components/Selector.USAGE.md +131 -0
  33. package/dist/runtime/components/SwitchButton.USAGE.md +62 -0
  34. package/dist/runtime/components/Tag.USAGE.md +51 -0
  35. package/dist/runtime/components/TaggableSelector.USAGE.md +73 -0
  36. package/dist/runtime/components/Textarea.USAGE.md +72 -0
  37. package/dist/runtime/components/Tooltip.USAGE.md +84 -0
  38. package/dist/runtime/components/ZoomableContainer.USAGE.md +108 -0
  39. package/dist/runtime/components/date/Picker.USAGE.md +8 -0
  40. package/dist/runtime/components/date/PickerTrigger.USAGE.md +65 -0
  41. package/dist/runtime/components/date/RangePicker.USAGE.md +97 -0
  42. package/dist/runtime/components/gallery/Carousel.USAGE.md +98 -0
  43. package/dist/runtime/components/gallery/CarouselPreview.USAGE.md +51 -0
  44. package/dist/runtime/components/upload/USAGE.md +91 -0
  45. package/dist/runtime/components/view/Dates.USAGE.md +67 -0
  46. package/dist/runtime/components/view/KeyBinds.USAGE.md +58 -0
  47. package/dist/runtime/components/view/Separator.USAGE.md +57 -0
  48. package/dist/runtime/components/view/Text.USAGE.md +68 -0
  49. package/dist/runtime/composables/useApi.USAGE.md +64 -0
  50. package/dist/runtime/composables/useControlSize.USAGE.md +73 -0
  51. package/dist/runtime/composables/useDecimalFormatter.USAGE.md +72 -0
  52. package/dist/runtime/composables/useFilter.USAGE.md +120 -0
  53. package/dist/runtime/composables/useFuzzySearch.USAGE.md +68 -0
  54. package/dist/runtime/composables/useInertia.USAGE.md +80 -0
  55. package/dist/runtime/composables/useListKeyboard.USAGE.md +97 -0
  56. package/dist/runtime/composables/useModal.USAGE.md +82 -0
  57. package/dist/runtime/composables/usePinchZoom.USAGE.md +95 -0
  58. package/dist/runtime/composables/usePressAndHold.USAGE.md +70 -0
  59. package/dist/runtime/composables/useRovingGrid.USAGE.md +106 -0
  60. package/dist/runtime/composables/useSound.USAGE.md +74 -0
  61. package/dist/runtime/composables/useTheme.USAGE.md +76 -0
  62. package/dist/runtime/composables/useUrlSync.USAGE.md +91 -0
  63. package/dist/runtime/composables/useValidation.USAGE.md +100 -0
  64. package/package.json +12 -2
@@ -0,0 +1,97 @@
1
+ ---
2
+ kind: composable
3
+ category: Composables
4
+ purpose: arrow-key flat list navigation, listbox keyboard, dropdown keys
5
+ short: arrow / Home / End / Enter / Space / Esc handling for a flat indexable list with auto scroll-into-view
6
+ invariants: true
7
+ ---
8
+
9
+ # useListKeyboard — agent-only invariants
10
+
11
+ `useListKeyboard` wires keyboard navigation for a flat list (listbox,
12
+ menu, dropdown). It owns the highlighted index, scrolls the highlighted
13
+ item into view, and emits open/close/select callbacks. Used internally by
14
+ `<orio-selector>`.
15
+
16
+ ## Invariants
17
+
18
+ - **`options.count()` is called on every navigation** — pass a function,
19
+ not a number. Lists that change size during interaction stay correct.
20
+ - **`options.onSelect(index)`** fires on Enter or Space when the list is
21
+ open and a row is highlighted (`highlightedIndex >= 0`).
22
+ - **`options.onOpen()`** fires when the user presses Arrow Down/Up,
23
+ Enter, or Space while the list is closed. Optional — without it,
24
+ closed-state key presses are no-ops.
25
+ - **`options.onClose()`** fires on Escape (plus resets the highlight to
26
+ `-1`). Optional.
27
+ - **`options.initialIndex()`** is called by `reset()` to set the
28
+ highlight on (re)open — typically returns the currently selected row.
29
+ Defaults to `0` when not provided.
30
+ - **Returned `listRef` MUST be attached to the list container** (the
31
+ `<ul>` / `<ol>`) so `scrollIntoView` can find the highlighted child.
32
+ Without it, the highlight still moves but never scrolls.
33
+ - **`onKeydown(e, isOpen)`** is the handler to wire on the trigger or
34
+ list. The caller passes `isOpen` because the composable does not own
35
+ open state.
36
+ - **Every matched key calls `e.preventDefault()`.** Unmatched keys
37
+ bubble. Space is registered as `" "` (single space string).
38
+ - **Highlight clamps to `[0, count - 1]`.** `highlight(-1)` lands on 0,
39
+ `highlight(99)` on `count - 1`.
40
+
41
+ ## Gotchas
42
+
43
+ - **Children must be direct descendants of the `listRef` element** for
44
+ scrollIntoView to target the correct row. Nested wrappers (a `<li>`
45
+ with deep children) break the index → `ul.children[index]` lookup.
46
+ - **Space (`" "`) selection collides with form behaviors.** Inside a
47
+ text input or button that already handles space, the preventDefault
48
+ may swallow it. Wire `onKeydown` on the listbox container or wrapping
49
+ focusable element, not on inputs.
50
+ - **`reset()` calls `scrollIntoView` on `nextTick`** — the list element
51
+ must be in the DOM by then. Calling `reset()` before mounting the
52
+ list won't scroll until after the next tick.
53
+ - **No type-ahead** ("press 'a' to jump to next item starting with a").
54
+ Implement upstream if needed.
55
+
56
+ ## Quick reference
57
+
58
+ ```ts
59
+ import { ref } from "vue";
60
+ import { useListKeyboard } from "../composables/useListKeyboard";
61
+
62
+ const isOpen = ref(false);
63
+ const items = ref<string[]>(["Alpha", "Bravo", "Charlie"]);
64
+ const selected = ref<string | null>(null);
65
+
66
+ const { highlightedIndex, listRef, onKeydown, reset } = useListKeyboard({
67
+ count: () => items.value.length,
68
+ onSelect: (index) => {
69
+ selected.value = items.value[index];
70
+ isOpen.value = false;
71
+ },
72
+ onOpen: () => {
73
+ isOpen.value = true;
74
+ reset();
75
+ },
76
+ onClose: () => (isOpen.value = false),
77
+ initialIndex: () => items.value.indexOf(selected.value ?? ""),
78
+ });
79
+ ```
80
+
81
+ ```vue
82
+ <template>
83
+ <button @keydown="onKeydown($event, isOpen)" @click="onOpen">Open</button>
84
+ <ul v-if="isOpen" ref="listRef">
85
+ <li v-for="(item, index) in items" :key="item"
86
+ :class="{ highlighted: index === highlightedIndex }">
87
+ {{ item }}
88
+ </li>
89
+ </ul>
90
+ </template>
91
+ ```
92
+
93
+ ## Related
94
+
95
+ - `<orio-selector>` — uses this composable internally.
96
+ - `useRovingGrid` — 2D arrow-key roving focus.
97
+ - Public API reference: `docs/composables/use-list-keyboard.md`.
@@ -0,0 +1,82 @@
1
+ ---
2
+ kind: composable
3
+ category: Composables
4
+ purpose: programmatic modal control, open modal from code, modal binding bag
5
+ short: returns a `modalProps` bag (show, origin, update handler) plus `openModal(event?)` that derives origin from a click target
6
+ invariants: true
7
+ ---
8
+
9
+ # useModal — agent-only invariants
10
+
11
+ `useModal` returns a binding bag for `<orio-modal>` plus an `openModal`
12
+ helper that captures the click target's rect for the open-from-origin
13
+ animation. Spread `modalProps` on the Modal — no manual `v-model:show`,
14
+ no `:origin` wiring.
15
+
16
+ ## Invariants
17
+
18
+ - **`modalProps` ref** has shape:
19
+ ```ts
20
+ {
21
+ show: boolean;
22
+ origin: OriginRect | null;
23
+ "onUpdate:show": (state: boolean) => void;
24
+ }
25
+ ```
26
+ Designed to be spread on `<orio-modal v-bind="modalProps">`. The
27
+ `"onUpdate:show"` listener is wired internally so the modal's close
28
+ button / backdrop click syncs back to the bag.
29
+ - **`OriginRect`** (exported): `{ x, y, width, height }` — derived from
30
+ `getBoundingClientRect()` of the event target.
31
+ - **`openModal(event?)`**:
32
+ - With an event: reads `event.target.getBoundingClientRect()` and
33
+ sets `origin` so the modal animates from the target. Sets `show`
34
+ to true.
35
+ - Without an event: clears `origin` to `null` and opens with a plain
36
+ fade.
37
+ - **No close helper** is returned — `modalProps.show = false` works, or
38
+ use the modal's built-in close button.
39
+
40
+ ## Gotchas
41
+
42
+ - **`event.target` is the clicked DOM node**, which may be a child of
43
+ the actual button (e.g. an icon inside). The rect then comes from
44
+ the icon, not the button — the animation origin will be tiny. To
45
+ capture the button itself, use `event.currentTarget` semantics by
46
+ reading the rect manually before calling `openModal`:
47
+ ```ts
48
+ function onClick(event: MouseEvent) {
49
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
50
+ modalProps.value.origin = rect;
51
+ modalProps.value.show = true;
52
+ }
53
+ ```
54
+ - **Only one modal per call site.** For multiple modals, call
55
+ `useModal()` once per modal — they don't share state.
56
+ - **No `v-model:show`** on the returned bag — the bag uses the explicit
57
+ `onUpdate:show` listener form because spreading an object cannot
58
+ declare v-model sugar.
59
+
60
+ ## Quick reference
61
+
62
+ ```vue
63
+ <script setup lang="ts">
64
+ import { useModal } from "../composables/useModal";
65
+
66
+ const { modalProps, openModal } = useModal();
67
+ </script>
68
+
69
+ <template>
70
+ <orio-button @click="openModal">Open</orio-button>
71
+
72
+ <orio-modal v-bind="modalProps" title="Settings">
73
+ <p>Modal body…</p>
74
+ </orio-modal>
75
+ </template>
76
+ ```
77
+
78
+ ## Related
79
+
80
+ - `<orio-modal>` — the consumer of this bag; supports `v-model:show`
81
+ and `origin` as plain props if you prefer manual wiring.
82
+ - Public API reference: `docs/composables/use-modal.md`.
@@ -0,0 +1,95 @@
1
+ ---
2
+ kind: composable
3
+ category: Composables
4
+ purpose: pinch-to-zoom, two-finger touch zoom, pinch gesture
5
+ short: touch-only pinch handler that tracks up to 2 pointers and exposes scale factor, midpoint, and midpoint delta
6
+ invariants: true
7
+ ---
8
+
9
+ # usePinchZoom — agent-only invariants
10
+
11
+ `usePinchZoom(callbacks)` tracks **touch** pointers (up to 2) and emits a
12
+ `scaleFactor` for two-finger gestures plus delegate callbacks for single-
13
+ finger phases. The consumer wires the returned handlers to pointer events.
14
+
15
+ ## Invariants
16
+
17
+ - **Touch-only.** `onPointerDown` returns `false` for non-touch (mouse,
18
+ pen) events. Mouse zoom needs separate wheel handling.
19
+ - **Tracks at most 2 pointers.** A third touch is ignored.
20
+ - **Callback shape**:
21
+ - `onPinchStart()` — fires when the second pointer goes down.
22
+ - `onPinchMove(scaleFactor, midX, midY, dx, dy)` — fires on each
23
+ two-finger move:
24
+ - `scaleFactor` = `currentDistance / startDistance` (cumulative
25
+ since pinch start).
26
+ - `midX`, `midY` = current viewport-coord midpoint between fingers.
27
+ - `dx`, `dy` = delta of the midpoint since the last move (for
28
+ pan-during-pinch).
29
+ - `onSingleDown(event)` — fires when the FIRST pointer goes down.
30
+ - `onSingleUp(remainingId, x, y)` — fires when one of two pointers
31
+ lifts, leaving one behind. Pass these to the caller's single-
32
+ pointer drag logic.
33
+ - `onAllUp()` — fires when the last pointer lifts.
34
+ - **Pointer capture is set on `event.currentTarget`** for every down.
35
+ The consumer must wire `onPointerDown` to a real element so capture
36
+ works.
37
+ - **`scaleFactor` is relative to pinch-start distance**, not the
38
+ previous frame. Multiply the consumer's "baseline scale at pinch
39
+ start" by `scaleFactor` to derive the new scale.
40
+ - **`startDist` resets** when pointer count drops from 2 → 1 or 0.
41
+ - **`pointers` is an exposed `Map`** keyed by pointerId — read it for
42
+ debugging.
43
+
44
+ ## Gotchas
45
+
46
+ - **Single-pointer drag is the consumer's responsibility.** The
47
+ composable only signals down/up; you wire the actual move handling
48
+ outside.
49
+ - **No pinch-axis lock.** Both x and y midpoint changes flow through;
50
+ diagonal pinch produces both zoom and pan. Filter in
51
+ `onPinchMove` if you want zoom-only.
52
+ - **`scaleFactor` can exceed sensible ranges** on fast pinches. Clamp
53
+ upstream (e.g. against `minScale` / `maxScale`).
54
+ - **PointerType check is exact `"touch"`.** Microsoft Surface pen
55
+ inputs and Apple Pencil have `pointerType: "pen"` — those won't
56
+ pinch via this composable.
57
+
58
+ ## Quick reference
59
+
60
+ ```ts
61
+ import { usePinchZoom } from "../composables/usePinchZoom";
62
+
63
+ let baseScale = 1;
64
+ const scale = ref(1);
65
+ const tx = ref(0);
66
+ const ty = ref(0);
67
+
68
+ const pinch = usePinchZoom({
69
+ onPinchStart() { baseScale = scale.value; },
70
+ onPinchMove(scaleFactor, midX, midY, dx, dy) {
71
+ scale.value = baseScale * scaleFactor;
72
+ tx.value += dx;
73
+ ty.value += dy;
74
+ // (also re-anchor scale around midpoint here)
75
+ },
76
+ onSingleDown(event) { /* hand off to single-pointer drag */ },
77
+ onSingleUp(id, x, y) { /* resume drag tracking on remaining finger */ },
78
+ });
79
+ ```
80
+
81
+ ```vue
82
+ <template>
83
+ <div
84
+ @pointerdown="pinch.onPointerDown"
85
+ @pointermove="pinch.onPointerMove"
86
+ @pointerup="pinch.onPointerUp"
87
+ />
88
+ </template>
89
+ ```
90
+
91
+ ## Related
92
+
93
+ - `<orio-zoomable-container>` — uses this for touch pinch.
94
+ - `<orio-canvas>` — same.
95
+ - Public API reference: `docs/composables/use-pinch-zoom.md`.
@@ -0,0 +1,70 @@
1
+ ---
2
+ kind: composable
3
+ category: Composables
4
+ purpose: long-press detection, press-and-hold, auto-repeat, mousedown-hold ramp
5
+ short: fires a callback once immediately, then repeats every 50 ms after a 500 ms hold
6
+ invariants: true
7
+ ---
8
+
9
+ # usePressAndHold — agent-only invariants
10
+
11
+ `usePressAndHold()` returns `{ pressAndHold, stop }` for press-and-hold
12
+ auto-repeat. Used by `<orio-number-input-horizontal>` and
13
+ `<orio-number-input-vertical>` to ramp values while a spinner button is
14
+ held.
15
+
16
+ ## Invariants
17
+
18
+ - **`pressAndHold(callback)` calls the callback once immediately**, then
19
+ starts a 500 ms timeout. After 500 ms, it begins calling the callback
20
+ every 50 ms via `setInterval`.
21
+ - **`stop()` clears both the pending timeout and the active interval.**
22
+ Always call on `mouseup`, `mouseleave`, and `pointercancel` — leaks
23
+ fire forever otherwise.
24
+ - **No reactivity, no state ref.** Just two functions. The internal
25
+ refs hold `setTimeout` / `setInterval` IDs only.
26
+ - **No press duration / counter.** The composable only knows
27
+ "callback fires." Track elapsed time in the callback if needed.
28
+
29
+ ## Gotchas
30
+
31
+ - **Timer leaks if `stop()` isn't called.** Common pitfall: forgetting
32
+ to wire `@mouseleave` means a user dragging off the button leaves
33
+ the interval running.
34
+ - **Touch behavior is not handled.** `@mousedown` does fire on touch
35
+ in most browsers but not all. For reliable mobile press-and-hold,
36
+ consider also wiring `@touchstart` / `@touchend`.
37
+ - **Re-entrancy not guarded.** Calling `pressAndHold(fn)` while a
38
+ previous press is active will create overlapping timers. Always
39
+ `stop()` before starting a new one.
40
+ - **Fixed timing (500 ms delay, 50 ms repeat).** Not configurable. Fork
41
+ if you need different cadence.
42
+
43
+ ## Quick reference
44
+
45
+ ```vue
46
+ <script setup lang="ts">
47
+ import { usePressAndHold } from "../composables/usePressAndHold";
48
+
49
+ const { pressAndHold, stop } = usePressAndHold();
50
+
51
+ let count = ref(0);
52
+ function increment() { count.value += 1; }
53
+ </script>
54
+
55
+ <template>
56
+ <button
57
+ @mousedown="pressAndHold(increment)"
58
+ @mouseup="stop"
59
+ @mouseleave="stop"
60
+ >
61
+ +1 ({{ count }})
62
+ </button>
63
+ </template>
64
+ ```
65
+
66
+ ## Related
67
+
68
+ - `<orio-number-input-horizontal>` — uses this for ± buttons.
69
+ - `<orio-number-input-vertical>` — uses this for chevron buttons.
70
+ - Public API reference: `docs/composables/use-press-and-hold.md`.
@@ -0,0 +1,106 @@
1
+ ---
2
+ kind: composable
3
+ category: Composables
4
+ purpose: roving-focus tabindex for 2D grids, grid keyboard navigation, calendar keyboard, table arrow nav
5
+ short: 2D arrow-key roving focus for grid-like UIs; handles arrows, Home/End, PageUp/Down, Enter/Space, and edge-overflow callbacks
6
+ invariants: true
7
+ ---
8
+
9
+ # useRovingGrid — agent-only invariants
10
+
11
+ `useRovingGrid` implements the WAI-ARIA roving-tabindex pattern for a 2D
12
+ grid. Generic over `Cell`. Used internally by `<orio-calendar>`.
13
+
14
+ ## Invariants
15
+
16
+ - **Only one cell has `tabindex="0"` at a time** — the active one. All
17
+ others get `-1`. The consumer wires this via `tabindexFor(key)`.
18
+ - **Cells must carry a `focus-key="<key>"` attribute** matching
19
+ `options.getKey(cell)` — `focusActive()` queries
20
+ `[focus-key="..."]` with `CSS.escape` for the lookup.
21
+ - **`rows`** is a `Ref` to a 2D array of cells. Rows may have
22
+ different column counts; navigation walks until it finds a valid
23
+ cell or hits an undefined.
24
+ - **`activeKey`** is a `ComputedRef<string>`:
25
+ - If the user has focused a cell, returns that key.
26
+ - Otherwise returns `options.initial()` — the consumer's "default
27
+ focus target" (typically today's date in a calendar).
28
+ - **`isNavigable(cell)`** (optional) — return `false` to skip cells
29
+ during arrow nav. Skipped cells are stepped past in the same
30
+ direction until a navigable cell is found, or the edge is hit.
31
+ - **`onArrowOverflow(direction, activeKey)`** — fires when an arrow
32
+ would move past the rendered grid edge. Return a new `key` to focus
33
+ (e.g. after paging to the next month). Return `null` for no-op.
34
+ - **`onPage(direction, bigJump, activeKey)`** — PageUp / PageDown.
35
+ `bigJump` is `event.shiftKey`. Same return contract as overflow.
36
+ - **`onActivate(cell)`** fires on Enter or Space on the active cell.
37
+ - **`onKeydown` calls `preventDefault` on every handled key**. Arrow,
38
+ Home, End, PageUp, PageDown, Enter, Space. Other keys bubble.
39
+ - **`focusActive()` runs on `nextTick`** — the DOM must reflect the
40
+ active key by then. `scrollIntoView` with `block: nearest, inline:
41
+ nearest, behavior: smooth`. Focus uses `preventScroll: true` (the
42
+ scroll already happened).
43
+
44
+ ## Gotchas
45
+
46
+ - **`getKey` must return strings** unique within the grid. Non-string
47
+ keys break the `focus-key` attribute lookup.
48
+ - **No `aria-activedescendant` mode.** This is a roving-tabindex
49
+ implementation only. For activedescendant grids, build separately.
50
+ - **`onArrowOverflow` must update backing state BEFORE returning the
51
+ new key.** The caller relies on the new key being findable in
52
+ `rows` on the next paint — usually the consumer mutates rows in
53
+ the same tick and returns the new key synchronously.
54
+ - **`isNavigable: () => false` everywhere** would freeze arrow nav.
55
+ Always leave at least the active cell navigable.
56
+
57
+ ## Quick reference — calendar-style 2D grid
58
+
59
+ ```ts
60
+ import { useRovingGrid } from "../composables/useRovingGrid";
61
+
62
+ interface Day { iso: string; inMonth: boolean }
63
+
64
+ const rows = ref<Day[][]>([...]); // 6 weeks × 7 days
65
+ const gridRef = ref<HTMLElement | null>(null);
66
+
67
+ const { activeKey, tabindexFor, onKeydown } = useRovingGrid({
68
+ rows,
69
+ gridRef,
70
+ getKey: (day) => day.iso,
71
+ initial: () => today.iso,
72
+ isNavigable: (day) => day.inMonth,
73
+ onActivate: (day) => emit("select", day.iso),
74
+ onArrowOverflow: (direction, activeKey) => {
75
+ pageMonth(direction); // mutates rows
76
+ return findNeighborIsoForOverflow(direction, activeKey);
77
+ },
78
+ onPage: (direction, bigJump) => {
79
+ pageMonth(direction, bigJump ? 12 : 1);
80
+ return today.iso;
81
+ },
82
+ });
83
+ ```
84
+
85
+ ```vue
86
+ <template>
87
+ <table ref="gridRef" @keydown="onKeydown">
88
+ <tr v-for="(week, weekIndex) in rows" :key="weekIndex">
89
+ <td
90
+ v-for="day in week"
91
+ :key="day.iso"
92
+ :focus-key="day.iso"
93
+ :tabindex="tabindexFor(day.iso)"
94
+ >
95
+ {{ day.iso }}
96
+ </td>
97
+ </tr>
98
+ </table>
99
+ </template>
100
+ ```
101
+
102
+ ## Related
103
+
104
+ - `<orio-calendar>` — uses this composable internally.
105
+ - `useListKeyboard` — 1D version for flat lists.
106
+ - Public API reference: `docs/composables/use-roving-grid.md`.
@@ -0,0 +1,74 @@
1
+ ---
2
+ kind: composable
3
+ category: Composables
4
+ purpose: audio cue playback, sound effect, UI click sound, beep
5
+ short: low-latency Web Audio playback with a shared module-level AudioContext and per-URL buffer cache
6
+ invariants: true
7
+ ---
8
+
9
+ # useSound — agent-only invariants
10
+
11
+ `useSound` returns `{ play, prefetch }`. It uses the Web Audio API (not
12
+ `<audio>` elements) for low-latency, gapless playback. State is
13
+ module-level — every consumer shares the same `AudioContext` and buffer
14
+ cache.
15
+
16
+ ## Invariants
17
+
18
+ - **Module-level singleton `AudioContext`**, created lazily on first
19
+ call. Same instance is reused across every `useSound()` consumer in
20
+ the page.
21
+ - **Module-level `bufferCache`** keyed by source URL. Multiple
22
+ consumers with the same `src` share decoded buffers.
23
+ - **Default sound is a remote URL** —
24
+ `https://cdn.jsdelivr.net/gh/oriondor/orio-ui@main/docs/public/sounds/mechanical-switch.wav`.
25
+ No offline / bundled fallback. Calling `play()` without a custom
26
+ `src` requires network on first use (cached thereafter by the
27
+ browser).
28
+ - **Default `volume` is `0.3`** (Web Audio Gain node). Range `0..1`;
29
+ values above clip ungracefully.
30
+ - **`prefetch: true`** in options eagerly fetches + decodes the buffer
31
+ at construction. Without it, the first `play()` includes
32
+ fetch + decode latency.
33
+ - **`play()` auto-resumes a suspended context.** Browsers gate audio on
34
+ a user gesture; the first call after a click/key event will resume
35
+ the context.
36
+ - **Returned `prefetch` warms the cache** for the configured `src` —
37
+ same as constructor `prefetch: true`, callable on demand.
38
+
39
+ ## Gotchas
40
+
41
+ - **Cross-origin / CDN dependency**: the default sound relies on
42
+ `cdn.jsdelivr.net`. For self-hosted apps, pass your own `src`.
43
+ - **No volume reactivity**: changing `volume` after construction does
44
+ nothing. Recreate `useSound({ volume: 0.5 })` to change the level.
45
+ - **Errors are swallowed** with `console.error`. There is no `loaded`
46
+ ref, no `error` ref, no promise resolution to await. `play()` returns
47
+ a resolved promise even if the buffer never loaded.
48
+ - **No autoplay before user gesture.** Calling `play()` before any user
49
+ interaction will silently fail in most browsers — the resume call
50
+ succeeds but the buffer source won't produce audio. Wire to a click
51
+ or keydown.
52
+ - **`AudioContext` is never closed.** Long-lived apps accumulate one
53
+ context; for very long sessions, this is usually fine because the
54
+ context is shared.
55
+
56
+ ## Quick reference
57
+
58
+ ```ts
59
+ import { useSound } from "../composables/useSound";
60
+
61
+ const { play, prefetch } = useSound({
62
+ src: "/sounds/click.wav",
63
+ volume: 0.4,
64
+ prefetch: true, // load + decode now, before first user interaction
65
+ });
66
+
67
+ // Later, on click:
68
+ button.addEventListener("click", () => play());
69
+ ```
70
+
71
+ ## Related
72
+
73
+ - `<orio-animated-container>` — exposes `play` via the slot prop bag.
74
+ - Public API reference: `docs/composables/use-sound.md`.
@@ -0,0 +1,76 @@
1
+ ---
2
+ kind: composable
3
+ category: Composables
4
+ purpose: theme tokens, light/dark, theme switcher, color theme
5
+ short: cookie-backed theme and mode (light/dark) accessor that writes `data-theme` and `data-mode` on `<html>`
6
+ invariants: true
7
+ ---
8
+
9
+ # useTheme — agent-only invariants
10
+
11
+ Returns `{ theme, setTheme, mode, setMode }`. State lives in cookies
12
+ (`COOKIE_NAMES.theme`, `COOKIE_NAMES.mode`) and is mirrored to
13
+ `data-theme` / `data-mode` attributes on the document element so CSS
14
+ selectors like `[data-theme="dark"]` work.
15
+
16
+ ## Invariants
17
+
18
+ - **Backed by `useCookies` from `@vueuse/integrations`.** Cookies are
19
+ set at path `/`. SSR-friendly because `useCookies` reads request
20
+ cookies during render.
21
+ - **Defaults come from `constants/theme.ts`** — `THEME_DEFAULTS.theme`
22
+ and `THEME_DEFAULTS.mode`. Override the defaults there, not at the
23
+ call site.
24
+ - **`theme` and `mode` are computed refs** with `get` / `set`. Writing
25
+ to them updates the cookie immediately. The `data-*` attributes on
26
+ `<html>` only refresh when you call `setTheme` / `setMode`, not on
27
+ raw assignment.
28
+ - **`onMounted(setHtmlAttrs)`** runs once per call site to apply
29
+ cookies to the document on the client.
30
+ - **SSR-safe**: `setHtmlAttrs` early-returns when `document` is
31
+ undefined.
32
+
33
+ ## Gotchas
34
+
35
+ - **Raw `theme.value = "dark"` writes the cookie but doesn't touch
36
+ `<html>` attrs.** Always go through `setTheme` / `setMode` if you
37
+ need the DOM to update in the same call.
38
+ - **Multiple `useTheme()` consumers don't share an in-memory state** —
39
+ the cookie is the source of truth. Reactivity across components
40
+ requires reading the same cookie via `useCookies` again.
41
+ - **Cookies are set at `path: "/"`.** If your app lives under a
42
+ sub-path, this still works but cookies are global to the domain. For
43
+ finer scoping, fork the composable.
44
+ - **No system / OS preference detection.** No `prefers-color-scheme`
45
+ fallback. Wire that in the consumer if needed.
46
+ - **`onMounted` per call** means N components → N attribute writes per
47
+ navigation. Cheap, but not idempotent at a single point.
48
+
49
+ ## Quick reference
50
+
51
+ ```ts
52
+ import { useTheme } from "../composables/useTheme";
53
+
54
+ const { theme, setTheme, mode, setMode } = useTheme();
55
+
56
+ // Switch theme
57
+ setTheme("ocean");
58
+ setMode("dark");
59
+
60
+ // Read current
61
+ console.log(theme.value, mode.value);
62
+ ```
63
+
64
+ ```vue
65
+ <template>
66
+ <orio-button @click="setMode(mode === 'dark' ? 'light' : 'dark')">
67
+ {{ mode === "dark" ? "☀" : "🌙" }}
68
+ </orio-button>
69
+ </template>
70
+ ```
71
+
72
+ ## Related
73
+
74
+ - `<orio-locale-switcher>` — sibling switch for vue-i18n locale.
75
+ - `constants/theme.ts` — defaults and cookie names live here.
76
+ - Public API reference: `docs/composables/use-theme.md`.