@octanejs/radix 0.1.2
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/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +45 -0
- package/src/AccessibleIcon.ts +26 -0
- package/src/Accordion.ts +282 -0
- package/src/AlertDialog.ts +110 -0
- package/src/Arrow.ts +21 -0
- package/src/AspectRatio.ts +34 -0
- package/src/Avatar.ts +152 -0
- package/src/Checkbox.ts +325 -0
- package/src/Collapsible.ts +170 -0
- package/src/ContextMenu.ts +303 -0
- package/src/Dialog.ts +308 -0
- package/src/DismissableLayer.ts +413 -0
- package/src/DropdownMenu.ts +275 -0
- package/src/FocusScope.ts +266 -0
- package/src/Form.ts +621 -0
- package/src/HoverCard.ts +318 -0
- package/src/Label.ts +25 -0
- package/src/Menu.ts +1057 -0
- package/src/Menubar.ts +572 -0
- package/src/NavigationMenu.ts +1236 -0
- package/src/OneTimePasswordField.ts +977 -0
- package/src/PasswordToggleField.ts +495 -0
- package/src/Popover.ts +354 -0
- package/src/Popper.ts +401 -0
- package/src/Portal.ts +19 -0
- package/src/Presence.ts +225 -0
- package/src/Primitive.ts +53 -0
- package/src/Progress.ts +92 -0
- package/src/Radio.ts +262 -0
- package/src/RadioGroup.ts +212 -0
- package/src/RovingFocusGroup.ts +259 -0
- package/src/ScrollArea.ts +966 -0
- package/src/Select.ts +1901 -0
- package/src/Separator.ts +36 -0
- package/src/Slider.ts +745 -0
- package/src/Slot.ts +105 -0
- package/src/Switch.ts +278 -0
- package/src/Tabs.ts +177 -0
- package/src/Toast.ts +923 -0
- package/src/Toggle.ts +31 -0
- package/src/ToggleGroup.ts +157 -0
- package/src/Toolbar.ts +130 -0
- package/src/Tooltip.ts +669 -0
- package/src/VisuallyHidden.ts +29 -0
- package/src/collection.ts +97 -0
- package/src/compose-event-handlers.ts +15 -0
- package/src/compose-refs.ts +53 -0
- package/src/context.ts +109 -0
- package/src/direction.ts +19 -0
- package/src/focus-guards.ts +56 -0
- package/src/index.ts +54 -0
- package/src/internal.ts +42 -0
- package/src/scroll-lock.ts +57 -0
- package/src/use-callback-ref.ts +28 -0
- package/src/use-effect-event.ts +36 -0
- package/src/use-is-hydrated.ts +23 -0
- package/src/use-previous.ts +28 -0
- package/src/use-size.ts +57 -0
- package/src/useControllableState.ts +78 -0
- package/src/useId.ts +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dominic Gannaway
|
|
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
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @octanejs/radix
|
|
2
|
+
|
|
3
|
+
[Radix UI Primitives](https://www.radix-ui.com/primitives) for the
|
|
4
|
+
[octane](https://github.com/octanejs/octane) renderer — a port of
|
|
5
|
+
[`@radix-ui/react`](https://www.npmjs.com/org/radix-ui) (headless, accessible UI
|
|
6
|
+
primitives) on octane's hooks. It mirrors the unified `radix-ui` package's shape: the
|
|
7
|
+
low-level composition utilities are exported directly, and each component is a namespace
|
|
8
|
+
(`Separator.Root`, `Label.Root`).
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
import { Separator, Label } from '@octanejs/radix';
|
|
12
|
+
|
|
13
|
+
function Field() {
|
|
14
|
+
return (
|
|
15
|
+
<div>
|
|
16
|
+
<Label.Root class="label">Email</Label.Root>
|
|
17
|
+
<Separator.Root orientation="horizontal" />
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## How it works
|
|
24
|
+
|
|
25
|
+
The React binding layer maps onto octane cleanly: `forwardRef` → octane's ref-as-prop,
|
|
26
|
+
`composeRefs` → `composeRefs`/`useComposedRefs`, and Radix's `Slot`/`asChild`
|
|
27
|
+
(`Children.only` + `cloneElement` + prop/ref merge) → octane's runtime `Children` /
|
|
28
|
+
`cloneElement` / `isValidElement`. Popper/focus/dismiss behavior reuses the already-ported
|
|
29
|
+
[`@octanejs/floating-ui`](../floating-ui). See
|
|
30
|
+
[`docs/radix-migration-plan.md`](../../docs/radix-migration-plan.md) for the full plan.
|
|
31
|
+
|
|
32
|
+
### `asChild` takes a descriptor
|
|
33
|
+
|
|
34
|
+
`Slot`/`asChild` operate on element **descriptors**. In `.tsrx`/`.tsx`, prop-position JSX
|
|
35
|
+
(`el={<button/>}`), `createElement`, and `.map()` returns are descriptors, but
|
|
36
|
+
children-position JSX compiles to a render function. So an octane `asChild` consumer passes
|
|
37
|
+
the child element at a value/prop position (or via `createElement`) rather than React's
|
|
38
|
+
children-position `<Trigger asChild><button/></Trigger>`.
|
|
39
|
+
|
|
40
|
+
## Status
|
|
41
|
+
|
|
42
|
+
**Complete against the unified `radix-ui@1.6.1` component surface.** Landed:
|
|
43
|
+
|
|
44
|
+
- Composition foundation — `Slot`, `Slottable`, `Primitive.<tag>` (`asChild`), `mergeProps`
|
|
45
|
+
(event chaining, `style` merge, clsx-style `class` composition), `composeRefs` /
|
|
46
|
+
`useComposedRefs`, `composeEventHandlers`.
|
|
47
|
+
- State foundation — `useControllableState` (controlled/uncontrolled), `Presence` (keeps a
|
|
48
|
+
child mounted through its CSS exit animation), the full **`createContextScope`**
|
|
49
|
+
(`createScope` + `composeContextScopes`) so composed primitives can't collide,
|
|
50
|
+
`createCollection` (the Collection primitive: `data-radix-collection-item` stamping +
|
|
51
|
+
DOM-ordered item registry), the `radix-`-prefixed `useId`, `useCallbackRef`,
|
|
52
|
+
`Direction` (`useDirection`/`Provider`), and `useSize`.
|
|
53
|
+
- Components — `Separator`, `Label`, `Collapsible`, `Accordion` (single + multiple, full
|
|
54
|
+
keyboard nav), **`Dialog`** (modal + non-modal), **`AlertDialog`**, **`Toggle`**,
|
|
55
|
+
**`ToggleGroup`**, **`Tabs`**, **`Toolbar`** (roving focus + embedded ToggleGroup),
|
|
56
|
+
`AspectRatio`, `VisuallyHidden`, `Avatar` (image loading state machine), `Progress`,
|
|
57
|
+
`Arrow` — the roving-focus family on the full **`RovingFocusGroup`** port.
|
|
58
|
+
- **The Popper overlay family** — **`Popper`** (anchor/content/arrow on
|
|
59
|
+
`@octanejs/floating-ui`'s positioning core: offset/shift/flip/size/arrow/hide +
|
|
60
|
+
transform-origin middleware, `--radix-popper-*` CSS vars, virtual anchors),
|
|
61
|
+
**`Tooltip`** (provider delays, skip-delay, grace-area convex hull, hidden a11y copy),
|
|
62
|
+
**`Popover`** (modal + non-modal, custom Anchor), **`HoverCard`** (open/close delays,
|
|
63
|
+
selection containment), the shared **`Menu`** primitive (typeahead, checkbox/radio
|
|
64
|
+
items + indicators, submenus with pointer-grace polygons), **`DropdownMenu`**, and
|
|
65
|
+
**`ContextMenu`** (right-click / long-press virtual anchor).
|
|
66
|
+
- **`ScrollArea`** — custom scrollbars over a native scroll viewport: all four
|
|
67
|
+
visibility strategies (`hover` / `scroll` state machine / `auto` overflow
|
|
68
|
+
measurement / `always`), thumb drag + wheel scroll geometry, corner, and the
|
|
69
|
+
scroll-linked-effect-avoiding rAF thumb loop.
|
|
70
|
+
- **The form batch** — **`Checkbox`** (indeterminate), **`Switch`**,
|
|
71
|
+
**`RadioGroup`** (roving-focus arrow keys check items), **`Slider`**
|
|
72
|
+
(multi-thumb, keyboard + pointer-capture sliding), and **`Form`** (native
|
|
73
|
+
Constraint Validation: built-in + custom sync/async matchers, messages wired
|
|
74
|
+
into `aria-describedby`). Each control renders Radix's hidden native "bubble
|
|
75
|
+
input" inside forms, so FormData, form reset, and `<form onChange>` reflect
|
|
76
|
+
state natively (see the per-file headers for the documented octane
|
|
77
|
+
adaptations around the uncontrolled-input model).
|
|
78
|
+
- **The final six** — **`Menubar`** (menu family over the shared Menu primitive),
|
|
79
|
+
**`Select`** (item-aligned + popper positioning, typeahead, hidden native bubble
|
|
80
|
+
`<select>`), **`NavigationMenu`** (viewport/indicator machinery, motion attributes,
|
|
81
|
+
delayed open/close), **`Toast`** (viewport hotkey, pausable timers, swipe machinery,
|
|
82
|
+
announce regions), **`OneTimePasswordField`** (per-char cells, paste distribution,
|
|
83
|
+
roving focus), and **`PasswordToggleField`** — plus `AccessibleIcon`,
|
|
84
|
+
`useEffectEvent`, and `useIsHydrated`.
|
|
85
|
+
- Overlay infra — `Portal`, `DismissableLayer`, `FocusScope`, `useFocusGuards`,
|
|
86
|
+
`useScrollLock` (a focused `react-remove-scroll` replacement — see `scroll-lock.ts`),
|
|
87
|
+
with the framework-agnostic `aria-hidden` package reused as-is.
|
|
88
|
+
|
|
89
|
+
**Verified against real Radix**: the differential suite
|
|
90
|
+
(`tests/differential/parity.test.ts`) runs the SAME fixture through `@octanejs/radix` and
|
|
91
|
+
the real `radix-ui` package on React, asserting byte-identical DOM after every interaction
|
|
92
|
+
step; portal'd overlays (which the rig can't see) carry dedicated focus/keyboard/dismiss
|
|
93
|
+
unit suites. Ports come from the pinned radix-ui/primitives source checkout
|
|
94
|
+
(`.radix-primitives/`); every file header cites its source path.
|
|
95
|
+
|
|
96
|
+
Remaining (documented in
|
|
97
|
+
[`docs/radix-migration-plan.md`](../../docs/radix-migration-plan.md)): SSR/hydration
|
|
98
|
+
coverage for the overlay/portal components and Phase-5 polish. The port surfaced —
|
|
99
|
+
and fixed, in octane itself — fourteen runtime/compiler parity bugs along the way;
|
|
100
|
+
each is pinned by an octane regression test and a changeset.
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/radix",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Radix UI Primitives for the octane renderer — a port of @radix-ui/react (headless, accessible UI primitives) on octane's hooks, built on the same @octanejs/floating-ui substrate.",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Dominic Gannaway",
|
|
9
|
+
"email": "dg@domgan.com"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
17
|
+
"directory": "packages/radix"
|
|
18
|
+
},
|
|
19
|
+
"main": "src/index.ts",
|
|
20
|
+
"module": "src/index.ts",
|
|
21
|
+
"types": "src/index.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./src/index.ts"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"aria-hidden": "^1.2.6",
|
|
31
|
+
"@octanejs/floating-ui": "0.1.2",
|
|
32
|
+
"octane": "0.1.3"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@tsrx/react": "^0.2.33",
|
|
36
|
+
"esbuild": "^0.28.1",
|
|
37
|
+
"radix-ui": "^1.4.3",
|
|
38
|
+
"react": "^19.2.0",
|
|
39
|
+
"react-dom": "^19.2.0",
|
|
40
|
+
"vitest": "^4.1.9"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "vitest run"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-accessible-icon (source:
|
|
2
|
+
// .radix-primitives/packages/react/accessible-icon/src/accessible-icon.tsx). Hides the
|
|
3
|
+
// icon child from ATs (aria-hidden + unfocusable SVG) and announces `label` through a
|
|
4
|
+
// VisuallyHidden copy instead — `alt` text for icons. octane note: like Slot/asChild,
|
|
5
|
+
// the icon child must be an element DESCRIPTOR (prop/value-position JSX or
|
|
6
|
+
// createElement); a single-element array unwraps, matching the Slot convention.
|
|
7
|
+
import { Children, cloneElement, createElement } from 'octane';
|
|
8
|
+
|
|
9
|
+
import * as VisuallyHiddenPrimitive from './VisuallyHidden';
|
|
10
|
+
|
|
11
|
+
export function Root(props: any): any {
|
|
12
|
+
const { children, label } = props ?? {};
|
|
13
|
+
const array = Children.toArray(children);
|
|
14
|
+
const child = array.length === 1 ? array[0] : Children.only(children);
|
|
15
|
+
return [
|
|
16
|
+
cloneElement(child as any, {
|
|
17
|
+
// accessibility
|
|
18
|
+
'aria-hidden': 'true',
|
|
19
|
+
focusable: 'false', // See: https://allyjs.io/tutorials/focusing-in-svg.html#making-svg-elements-focusable
|
|
20
|
+
key: 'icon',
|
|
21
|
+
}),
|
|
22
|
+
createElement(VisuallyHiddenPrimitive.Root, { key: 'label', children: label }),
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { Root as AccessibleIcon };
|
package/src/Accordion.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-accordion. A set of collapsible sections. Each Item is a
|
|
2
|
+
// Collapsible whose `open` is derived from the Accordion's controllable `value` (a single
|
|
3
|
+
// string for `type="single"`, or a string[] for `type="multiple"`). `.ts` components via
|
|
4
|
+
// createElement. Uses Radix's scoped context: `createAccordionScope` composes
|
|
5
|
+
// `createCollapsibleScope`, so each Item threads its own isolated Collapsible scope
|
|
6
|
+
// (`__scopeCollapsible`) — a user's separate Collapsible won't collide with an Accordion's.
|
|
7
|
+
//
|
|
8
|
+
// DEFERRED: arrow-key roving focus between triggers (Radix wraps triggers in
|
|
9
|
+
// RovingFocusGroup). Expand/collapse + ARIA + data-state are complete; the roving-focus
|
|
10
|
+
// primitive is a separate, reusable follow-up (also used by Tabs/Toolbar/RadioGroup).
|
|
11
|
+
import { createElement, useCallback } from 'octane';
|
|
12
|
+
|
|
13
|
+
import * as Collapsible from './Collapsible';
|
|
14
|
+
import { createCollapsibleScope } from './Collapsible';
|
|
15
|
+
import { createCollection } from './collection';
|
|
16
|
+
import { composeEventHandlers } from './compose-event-handlers';
|
|
17
|
+
import { createContextScope } from './context';
|
|
18
|
+
import { S, subSlot } from './internal';
|
|
19
|
+
import { Primitive } from './Primitive';
|
|
20
|
+
import { useControllableState } from './useControllableState';
|
|
21
|
+
import { useId } from './useId';
|
|
22
|
+
|
|
23
|
+
const ACCORDION_KEYS = ['Home', 'End', 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'];
|
|
24
|
+
const [Collection, useCollection] = createCollection('Accordion');
|
|
25
|
+
|
|
26
|
+
interface ValueContextValue {
|
|
27
|
+
value: string[];
|
|
28
|
+
onItemOpen: (value: string) => void;
|
|
29
|
+
onItemClose: (value: string) => void;
|
|
30
|
+
}
|
|
31
|
+
interface ImplContextValue {
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
orientation: 'horizontal' | 'vertical';
|
|
34
|
+
collapsible: boolean;
|
|
35
|
+
isMultiple: boolean;
|
|
36
|
+
}
|
|
37
|
+
interface ItemContextValue {
|
|
38
|
+
open: boolean;
|
|
39
|
+
disabled?: boolean;
|
|
40
|
+
triggerId: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const [createAccordionContext, createAccordionScope] = createContextScope('Accordion', [
|
|
44
|
+
createCollapsibleScope,
|
|
45
|
+
]);
|
|
46
|
+
export { createAccordionScope };
|
|
47
|
+
const useCollapsibleScope = createCollapsibleScope();
|
|
48
|
+
|
|
49
|
+
const [AccordionValueProvider, useAccordionValueContext] =
|
|
50
|
+
createAccordionContext<ValueContextValue>('Accordion');
|
|
51
|
+
const [AccordionImplProvider, useAccordionImplContext] =
|
|
52
|
+
createAccordionContext<ImplContextValue>('Accordion');
|
|
53
|
+
const [AccordionItemProvider, useAccordionItemContext] =
|
|
54
|
+
createAccordionContext<ItemContextValue>('AccordionItem');
|
|
55
|
+
|
|
56
|
+
function getState(open?: boolean): 'open' | 'closed' {
|
|
57
|
+
return open ? 'open' : 'closed';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function Root(props: any): any {
|
|
61
|
+
const slot = S('Accordion.Root');
|
|
62
|
+
const {
|
|
63
|
+
__scopeAccordion,
|
|
64
|
+
type,
|
|
65
|
+
value: valueProp,
|
|
66
|
+
defaultValue,
|
|
67
|
+
onValueChange,
|
|
68
|
+
collapsible = false,
|
|
69
|
+
disabled,
|
|
70
|
+
dir,
|
|
71
|
+
orientation = 'vertical',
|
|
72
|
+
...rest
|
|
73
|
+
} = props ?? {};
|
|
74
|
+
const isMultiple = type === 'multiple';
|
|
75
|
+
|
|
76
|
+
const [value, setValue] = useControllableState<any>(
|
|
77
|
+
{
|
|
78
|
+
prop: valueProp,
|
|
79
|
+
defaultProp: defaultValue ?? (isMultiple ? [] : undefined),
|
|
80
|
+
onChange: onValueChange,
|
|
81
|
+
},
|
|
82
|
+
subSlot(slot, 'value'),
|
|
83
|
+
);
|
|
84
|
+
const valueArray: string[] = isMultiple
|
|
85
|
+
? (value ?? [])
|
|
86
|
+
: value != null && value !== ''
|
|
87
|
+
? [value]
|
|
88
|
+
: [];
|
|
89
|
+
|
|
90
|
+
const onItemOpen = useCallback(
|
|
91
|
+
(itemValue: string) => {
|
|
92
|
+
if (isMultiple) setValue((prev: string[]) => [...(prev ?? []), itemValue]);
|
|
93
|
+
else setValue(itemValue);
|
|
94
|
+
},
|
|
95
|
+
[isMultiple],
|
|
96
|
+
subSlot(slot, 'open'),
|
|
97
|
+
);
|
|
98
|
+
const onItemClose = useCallback(
|
|
99
|
+
(itemValue: string) => {
|
|
100
|
+
if (isMultiple) setValue((prev: string[]) => (prev ?? []).filter((v) => v !== itemValue));
|
|
101
|
+
else if (collapsible) setValue('');
|
|
102
|
+
},
|
|
103
|
+
[isMultiple, collapsible],
|
|
104
|
+
subSlot(slot, 'close'),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return createElement(Collection.Provider, {
|
|
108
|
+
scope: __scopeAccordion,
|
|
109
|
+
children: createElement(AccordionValueProvider, {
|
|
110
|
+
scope: __scopeAccordion,
|
|
111
|
+
value: valueArray,
|
|
112
|
+
onItemOpen,
|
|
113
|
+
onItemClose,
|
|
114
|
+
children: createElement(AccordionImplProvider, {
|
|
115
|
+
scope: __scopeAccordion,
|
|
116
|
+
disabled,
|
|
117
|
+
orientation,
|
|
118
|
+
collapsible,
|
|
119
|
+
isMultiple,
|
|
120
|
+
children: createElement(AccordionImpl, {
|
|
121
|
+
__scopeAccordion,
|
|
122
|
+
disabled,
|
|
123
|
+
dir,
|
|
124
|
+
orientation,
|
|
125
|
+
...rest,
|
|
126
|
+
}),
|
|
127
|
+
}),
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The keyboard-navigable root element (Radix's AccordionImpl): Home/End jump, arrow keys
|
|
133
|
+
// move between triggers per orientation/direction, wrapping — driven by the Collection.
|
|
134
|
+
function AccordionImpl(props: any): any {
|
|
135
|
+
const slot = S('Accordion.Impl');
|
|
136
|
+
const { __scopeAccordion, disabled, dir, orientation, ...accordionProps } = props;
|
|
137
|
+
const getItems = useCollection(__scopeAccordion, subSlot(slot, 'items'));
|
|
138
|
+
const isDirectionLTR = (dir ?? 'ltr') === 'ltr';
|
|
139
|
+
|
|
140
|
+
const handleKeyDown = composeEventHandlers(accordionProps.onKeyDown, (event: KeyboardEvent) => {
|
|
141
|
+
if (!ACCORDION_KEYS.includes(event.key)) return;
|
|
142
|
+
const target = event.target as HTMLElement;
|
|
143
|
+
const triggerCollection = getItems().filter((item: any) => !item.ref.current?.disabled);
|
|
144
|
+
const triggerIndex = triggerCollection.findIndex((item: any) => item.ref.current === target);
|
|
145
|
+
const triggerCount = triggerCollection.length;
|
|
146
|
+
if (triggerIndex === -1) return;
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
let nextIndex = triggerIndex;
|
|
149
|
+
const homeIndex = 0;
|
|
150
|
+
const endIndex = triggerCount - 1;
|
|
151
|
+
const moveNext = (): void => {
|
|
152
|
+
nextIndex = triggerIndex + 1;
|
|
153
|
+
if (nextIndex > endIndex) nextIndex = homeIndex;
|
|
154
|
+
};
|
|
155
|
+
const movePrev = (): void => {
|
|
156
|
+
nextIndex = triggerIndex - 1;
|
|
157
|
+
if (nextIndex < homeIndex) nextIndex = endIndex;
|
|
158
|
+
};
|
|
159
|
+
switch (event.key) {
|
|
160
|
+
case 'Home':
|
|
161
|
+
nextIndex = homeIndex;
|
|
162
|
+
break;
|
|
163
|
+
case 'End':
|
|
164
|
+
nextIndex = endIndex;
|
|
165
|
+
break;
|
|
166
|
+
case 'ArrowRight':
|
|
167
|
+
if (orientation === 'horizontal') {
|
|
168
|
+
if (isDirectionLTR) moveNext();
|
|
169
|
+
else movePrev();
|
|
170
|
+
}
|
|
171
|
+
break;
|
|
172
|
+
case 'ArrowDown':
|
|
173
|
+
if (orientation === 'vertical') moveNext();
|
|
174
|
+
break;
|
|
175
|
+
case 'ArrowLeft':
|
|
176
|
+
if (orientation === 'horizontal') {
|
|
177
|
+
if (isDirectionLTR) movePrev();
|
|
178
|
+
else moveNext();
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
case 'ArrowUp':
|
|
182
|
+
if (orientation === 'vertical') movePrev();
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
triggerCollection[nextIndex % triggerCount].ref.current?.focus();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return createElement(Collection.Slot, {
|
|
189
|
+
scope: __scopeAccordion,
|
|
190
|
+
children: createElement(Primitive.div, {
|
|
191
|
+
...accordionProps,
|
|
192
|
+
'data-orientation': orientation,
|
|
193
|
+
onKeyDown: disabled ? undefined : handleKeyDown,
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function Item(props: any): any {
|
|
199
|
+
const slot = S('Accordion.Item');
|
|
200
|
+
const { __scopeAccordion, value: itemValue, disabled: itemDisabled, ...rest } = props ?? {};
|
|
201
|
+
const impl = useAccordionImplContext('AccordionItem', __scopeAccordion);
|
|
202
|
+
const valueCtx = useAccordionValueContext('AccordionItem', __scopeAccordion);
|
|
203
|
+
const collapsibleScope = useCollapsibleScope(__scopeAccordion, subSlot(slot, 'cscope'));
|
|
204
|
+
const triggerId = useId(subSlot(slot, 'id'));
|
|
205
|
+
const open = (itemValue != null && valueCtx.value.includes(itemValue)) || false;
|
|
206
|
+
const disabled = impl.disabled || itemDisabled || false;
|
|
207
|
+
|
|
208
|
+
return createElement(AccordionItemProvider, {
|
|
209
|
+
scope: __scopeAccordion,
|
|
210
|
+
open,
|
|
211
|
+
disabled,
|
|
212
|
+
triggerId,
|
|
213
|
+
children: createElement(Collapsible.Root, {
|
|
214
|
+
'data-orientation': impl.orientation,
|
|
215
|
+
'data-state': getState(open),
|
|
216
|
+
...collapsibleScope,
|
|
217
|
+
...rest,
|
|
218
|
+
disabled,
|
|
219
|
+
open,
|
|
220
|
+
onOpenChange: (isOpen: boolean) => {
|
|
221
|
+
if (isOpen) valueCtx.onItemOpen(itemValue);
|
|
222
|
+
else valueCtx.onItemClose(itemValue);
|
|
223
|
+
},
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function Header(props: any): any {
|
|
229
|
+
const { __scopeAccordion, ...rest } = props ?? {};
|
|
230
|
+
const impl = useAccordionImplContext('AccordionHeader', __scopeAccordion);
|
|
231
|
+
const item = useAccordionItemContext('AccordionHeader', __scopeAccordion);
|
|
232
|
+
return createElement(Primitive.h3, {
|
|
233
|
+
'data-orientation': impl.orientation,
|
|
234
|
+
'data-state': getState(item.open),
|
|
235
|
+
'data-disabled': item.disabled ? '' : undefined,
|
|
236
|
+
...rest,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function Trigger(props: any): any {
|
|
241
|
+
const slot = S('Accordion.Trigger');
|
|
242
|
+
const { __scopeAccordion, ...rest } = props ?? {};
|
|
243
|
+
const impl = useAccordionImplContext('AccordionTrigger', __scopeAccordion);
|
|
244
|
+
const item = useAccordionItemContext('AccordionTrigger', __scopeAccordion);
|
|
245
|
+
const collapsibleScope = useCollapsibleScope(__scopeAccordion, subSlot(slot, 'cscope'));
|
|
246
|
+
// A `type="single"` non-collapsible open item can't be closed → aria-disabled.
|
|
247
|
+
const ariaDisabled = (item.open && !impl.isMultiple && !impl.collapsible) || undefined;
|
|
248
|
+
// Registered in the Collection (stamps `data-radix-collection-item` + a ref onto the
|
|
249
|
+
// underlying button) so the root's keyboard nav can enumerate triggers in DOM order.
|
|
250
|
+
return createElement(Collection.ItemSlot, {
|
|
251
|
+
scope: __scopeAccordion,
|
|
252
|
+
children: createElement(Collapsible.Trigger, {
|
|
253
|
+
'aria-disabled': ariaDisabled,
|
|
254
|
+
'data-orientation': impl.orientation,
|
|
255
|
+
id: item.triggerId,
|
|
256
|
+
...collapsibleScope,
|
|
257
|
+
...rest,
|
|
258
|
+
}),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function Content(props: any): any {
|
|
263
|
+
const slot = S('Accordion.Content');
|
|
264
|
+
const { __scopeAccordion, style, ...rest } = props ?? {};
|
|
265
|
+
const impl = useAccordionImplContext('AccordionContent', __scopeAccordion);
|
|
266
|
+
const item = useAccordionItemContext('AccordionContent', __scopeAccordion);
|
|
267
|
+
const collapsibleScope = useCollapsibleScope(__scopeAccordion, subSlot(slot, 'cscope'));
|
|
268
|
+
return createElement(Collapsible.Content, {
|
|
269
|
+
role: 'region',
|
|
270
|
+
'aria-labelledby': item.triggerId,
|
|
271
|
+
'data-orientation': impl.orientation,
|
|
272
|
+
...collapsibleScope,
|
|
273
|
+
...rest,
|
|
274
|
+
style: {
|
|
275
|
+
'--radix-accordion-content-height': 'var(--radix-collapsible-content-height)',
|
|
276
|
+
'--radix-accordion-content-width': 'var(--radix-collapsible-content-width)',
|
|
277
|
+
...style,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export { Root as Accordion };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-alert-dialog. A modal confirmation dialog built as a thin
|
|
2
|
+
// wrapper over Dialog: always `modal`, `role="alertdialog"`, outside interactions never
|
|
3
|
+
// dismiss (pointer-down/interact-outside prevented), and opening autofocuses the CANCEL
|
|
4
|
+
// action (the safe choice) instead of the first tabbable.
|
|
5
|
+
import { createElement, useRef } from 'octane';
|
|
6
|
+
|
|
7
|
+
import { composeEventHandlers } from './compose-event-handlers';
|
|
8
|
+
import { useComposedRefs } from './compose-refs';
|
|
9
|
+
import { createContextScope } from './context';
|
|
10
|
+
import * as DialogPrimitive from './Dialog';
|
|
11
|
+
import { createDialogScope } from './Dialog';
|
|
12
|
+
import { S, subSlot } from './internal';
|
|
13
|
+
|
|
14
|
+
const [createAlertDialogContext, createAlertDialogScope] = createContextScope('AlertDialog', [
|
|
15
|
+
createDialogScope,
|
|
16
|
+
]);
|
|
17
|
+
export { createAlertDialogScope };
|
|
18
|
+
const useDialogScope = createDialogScope();
|
|
19
|
+
|
|
20
|
+
const [AlertDialogContentProvider, useAlertDialogContentContext] = createAlertDialogContext<{
|
|
21
|
+
cancelRef: { current: HTMLElement | null };
|
|
22
|
+
}>('AlertDialogContent');
|
|
23
|
+
|
|
24
|
+
export function Root(props: any): any {
|
|
25
|
+
const slot = S('AlertDialog.Root');
|
|
26
|
+
const { __scopeAlertDialog, ...alertDialogProps } = props ?? {};
|
|
27
|
+
const dialogScope = useDialogScope(__scopeAlertDialog, subSlot(slot, 'scope'));
|
|
28
|
+
return createElement(DialogPrimitive.Root, { ...dialogScope, ...alertDialogProps, modal: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function Trigger(props: any): any {
|
|
32
|
+
const slot = S('AlertDialog.Trigger');
|
|
33
|
+
const { __scopeAlertDialog, ...triggerProps } = props ?? {};
|
|
34
|
+
const dialogScope = useDialogScope(__scopeAlertDialog, subSlot(slot, 'scope'));
|
|
35
|
+
return createElement(DialogPrimitive.Trigger, { ...dialogScope, ...triggerProps });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Portal(props: any): any {
|
|
39
|
+
const slot = S('AlertDialog.Portal');
|
|
40
|
+
const { __scopeAlertDialog, ...portalProps } = props ?? {};
|
|
41
|
+
const dialogScope = useDialogScope(__scopeAlertDialog, subSlot(slot, 'scope'));
|
|
42
|
+
return createElement(DialogPrimitive.Portal, { ...dialogScope, ...portalProps });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function Overlay(props: any): any {
|
|
46
|
+
const slot = S('AlertDialog.Overlay');
|
|
47
|
+
const { __scopeAlertDialog, ...overlayProps } = props ?? {};
|
|
48
|
+
const dialogScope = useDialogScope(__scopeAlertDialog, subSlot(slot, 'scope'));
|
|
49
|
+
return createElement(DialogPrimitive.Overlay, { ...dialogScope, ...overlayProps });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function Content(props: any): any {
|
|
53
|
+
const slot = S('AlertDialog.Content');
|
|
54
|
+
const { __scopeAlertDialog, children, ref: forwardedRef, ...contentProps } = props ?? {};
|
|
55
|
+
const dialogScope = useDialogScope(__scopeAlertDialog, subSlot(slot, 'scope'));
|
|
56
|
+
const contentRef = useRef<HTMLElement | null>(null, subSlot(slot, 'content'));
|
|
57
|
+
const composedRefs = useComposedRefs(forwardedRef, contentRef, subSlot(slot, 'refs'));
|
|
58
|
+
const cancelRef = useRef<HTMLElement | null>(null, subSlot(slot, 'cancel'));
|
|
59
|
+
return createElement(AlertDialogContentProvider, {
|
|
60
|
+
scope: __scopeAlertDialog,
|
|
61
|
+
cancelRef,
|
|
62
|
+
children: createElement(DialogPrimitive.Content, {
|
|
63
|
+
role: 'alertdialog',
|
|
64
|
+
...dialogScope,
|
|
65
|
+
...contentProps,
|
|
66
|
+
ref: composedRefs,
|
|
67
|
+
onOpenAutoFocus: composeEventHandlers(contentProps.onOpenAutoFocus, (event: Event) => {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
cancelRef.current?.focus({ preventScroll: true });
|
|
70
|
+
}),
|
|
71
|
+
onPointerDownOutside: (event: Event) => event.preventDefault(),
|
|
72
|
+
onInteractOutside: (event: Event) => event.preventDefault(),
|
|
73
|
+
children,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function Title(props: any): any {
|
|
79
|
+
const slot = S('AlertDialog.Title');
|
|
80
|
+
const { __scopeAlertDialog, ...titleProps } = props ?? {};
|
|
81
|
+
const dialogScope = useDialogScope(__scopeAlertDialog, subSlot(slot, 'scope'));
|
|
82
|
+
return createElement(DialogPrimitive.Title, { ...dialogScope, ...titleProps });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function Description(props: any): any {
|
|
86
|
+
const slot = S('AlertDialog.Description');
|
|
87
|
+
const { __scopeAlertDialog, ...descriptionProps } = props ?? {};
|
|
88
|
+
const dialogScope = useDialogScope(__scopeAlertDialog, subSlot(slot, 'scope'));
|
|
89
|
+
return createElement(DialogPrimitive.Description, { ...dialogScope, ...descriptionProps });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Confirms the action — closes the dialog (a `Dialog.Close`). */
|
|
93
|
+
export function Action(props: any): any {
|
|
94
|
+
const slot = S('AlertDialog.Action');
|
|
95
|
+
const { __scopeAlertDialog, ...actionProps } = props ?? {};
|
|
96
|
+
const dialogScope = useDialogScope(__scopeAlertDialog, subSlot(slot, 'scope'));
|
|
97
|
+
return createElement(DialogPrimitive.Close, { ...dialogScope, ...actionProps });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Cancels — closes the dialog and is the element autofocused on open. */
|
|
101
|
+
export function Cancel(props: any): any {
|
|
102
|
+
const slot = S('AlertDialog.Cancel');
|
|
103
|
+
const { __scopeAlertDialog, ref: forwardedRef, ...cancelProps } = props ?? {};
|
|
104
|
+
const { cancelRef } = useAlertDialogContentContext('AlertDialogCancel', __scopeAlertDialog);
|
|
105
|
+
const dialogScope = useDialogScope(__scopeAlertDialog, subSlot(slot, 'scope'));
|
|
106
|
+
const ref = useComposedRefs(forwardedRef, cancelRef, subSlot(slot, 'refs'));
|
|
107
|
+
return createElement(DialogPrimitive.Close, { ...dialogScope, ...cancelProps, ref });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export { Root as AlertDialog };
|
package/src/Arrow.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-arrow (source:
|
|
2
|
+
// .radix-primitives/packages/react/arrow/src/arrow.tsx). The default popper arrow: a
|
|
3
|
+
// 30×10 viewBox svg polygon, stretchable via width/height.
|
|
4
|
+
import { createElement } from 'octane';
|
|
5
|
+
|
|
6
|
+
import { Primitive } from './Primitive';
|
|
7
|
+
|
|
8
|
+
export function Arrow(props: any): any {
|
|
9
|
+
const { children, width = 10, height = 5, ...arrowProps } = props ?? {};
|
|
10
|
+
return createElement(Primitive.svg, {
|
|
11
|
+
...arrowProps,
|
|
12
|
+
width,
|
|
13
|
+
height,
|
|
14
|
+
viewBox: '0 0 30 10',
|
|
15
|
+
preserveAspectRatio: 'none',
|
|
16
|
+
// We use their children if they're slotting to replace the whole svg.
|
|
17
|
+
children: props?.asChild ? children : createElement('polygon', { points: '0,0 30,0 15,10' }),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { Arrow as Root };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-aspect-ratio (source:
|
|
2
|
+
// .radix-primitives/packages/react/aspect-ratio/src/aspect-ratio.tsx). Constrains content
|
|
3
|
+
// to a ratio via the padding-bottom trick on a wrapper div.
|
|
4
|
+
import { createElement } from 'octane';
|
|
5
|
+
|
|
6
|
+
import { Primitive } from './Primitive';
|
|
7
|
+
|
|
8
|
+
export function AspectRatio(props: any): any {
|
|
9
|
+
const { ratio = 1 / 1, style, ...aspectRatioProps } = props ?? {};
|
|
10
|
+
return createElement('div', {
|
|
11
|
+
style: {
|
|
12
|
+
// ensures inner element is contained
|
|
13
|
+
position: 'relative',
|
|
14
|
+
// ensures padding bottom trick maths works
|
|
15
|
+
width: '100%',
|
|
16
|
+
paddingBottom: `${100 / ratio}%`,
|
|
17
|
+
},
|
|
18
|
+
'data-radix-aspect-ratio-wrapper': '',
|
|
19
|
+
children: createElement(Primitive.div, {
|
|
20
|
+
...aspectRatioProps,
|
|
21
|
+
style: {
|
|
22
|
+
...style,
|
|
23
|
+
// ensures children expand in ratio
|
|
24
|
+
position: 'absolute',
|
|
25
|
+
top: 0,
|
|
26
|
+
right: 0,
|
|
27
|
+
bottom: 0,
|
|
28
|
+
left: 0,
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { AspectRatio as Root };
|