@octanejs/cmdk 0.1.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.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +52 -0
- package/src/cmdk.tsrx +52 -0
- package/src/cmdk.tsrx.d.ts +49 -0
- package/src/command-score.ts +177 -0
- package/src/components/command.tsrx +729 -0
- package/src/components/dialog.tsrx +40 -0
- package/src/components/empty.tsrx +28 -0
- package/src/components/group.tsrx +52 -0
- package/src/components/input.tsrx +44 -0
- package/src/components/item.tsrx +95 -0
- package/src/components/list.tsrx +50 -0
- package/src/components/loading.tsrx +19 -0
- package/src/components/separator.tsrx +14 -0
- package/src/context.tsrx +12 -0
- package/src/filter.ts +14 -0
- package/src/hooks.tsrx +125 -0
- package/src/index.ts +31 -0
- package/src/lib/constants.ts +23 -0
- package/src/types.ts +171 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Command.Dialog. Renders the command menu inside an @octanejs/radix Dialog.
|
|
2
|
+
// Built with createElement rather than JSX children: radix's Dialog.Portal
|
|
3
|
+
// iterates its children via Children.map, which needs real element descriptors —
|
|
4
|
+
// octane's compiled .tsrx children would be opaque (the documented radix
|
|
5
|
+
// asChild/Slot divergence). The overlay + content are passed as descriptor
|
|
6
|
+
// children.
|
|
7
|
+
import { createElement } from 'octane';
|
|
8
|
+
import { Dialog as RadixDialog } from '@octanejs/radix';
|
|
9
|
+
import { CommandRoot } from './command.tsrx';
|
|
10
|
+
import type { DialogProps } from '../types';
|
|
11
|
+
|
|
12
|
+
export function Dialog(props: DialogProps) {
|
|
13
|
+
const {
|
|
14
|
+
open,
|
|
15
|
+
defaultOpen,
|
|
16
|
+
onOpenChange,
|
|
17
|
+
modal,
|
|
18
|
+
overlayClassName,
|
|
19
|
+
contentClassName,
|
|
20
|
+
container,
|
|
21
|
+
label,
|
|
22
|
+
ref: _ref,
|
|
23
|
+
...etc
|
|
24
|
+
} = props;
|
|
25
|
+
|
|
26
|
+
return createElement(
|
|
27
|
+
RadixDialog.Root,
|
|
28
|
+
{ open, defaultOpen, onOpenChange, modal },
|
|
29
|
+
createElement(
|
|
30
|
+
RadixDialog.Portal,
|
|
31
|
+
{ container },
|
|
32
|
+
createElement(RadixDialog.Overlay, { 'cmdk-overlay': '', className: overlayClassName }),
|
|
33
|
+
createElement(
|
|
34
|
+
RadixDialog.Content,
|
|
35
|
+
{ 'aria-label': label, 'cmdk-dialog': '', className: contentClassName },
|
|
36
|
+
createElement(CommandRoot, { ref: props.ref, label, ...etc }),
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Command.Empty. Renders only when the filter matched nothing.
|
|
2
|
+
import { useSyncExternalStore } from 'octane';
|
|
3
|
+
import { useStore } from '../context.tsrx';
|
|
4
|
+
import type { EmptyProps } from '../types';
|
|
5
|
+
|
|
6
|
+
export function Empty(props: EmptyProps) @{
|
|
7
|
+
const store = useStore()!;
|
|
8
|
+
|
|
9
|
+
// Force-mounted items never enter `filtered.count`, so consult both: "no
|
|
10
|
+
// results" must not render while any item is visible.
|
|
11
|
+
const snapshot = () => {
|
|
12
|
+
const state = store.snapshot();
|
|
13
|
+
return state.filtered.count === 0 && state.forceMountedCount === 0;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// The server snapshot is `false`. Items register in layout effects, which do
|
|
17
|
+
// not run on the server, so the count is unavoidably 0 there — rendering on
|
|
18
|
+
// that would ship "No results found." above a fully-populated list on every
|
|
19
|
+
// SSR page and leave it there for readers without JavaScript. This subscribes
|
|
20
|
+
// directly rather than through `useCmdk` because a server snapshot passed to
|
|
21
|
+
// a base hook from inside a custom .tsrx hook does not reach the SSR runtime.
|
|
22
|
+
const render = useSyncExternalStore(store.subscribe, snapshot, () => false);
|
|
23
|
+
|
|
24
|
+
if (!render) return null;
|
|
25
|
+
|
|
26
|
+
const { ref: _ref, children, ...etc } = props;
|
|
27
|
+
<div ref={props.ref} {...etc} cmdk-empty="" role="presentation">{children}</div>
|
|
28
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Command.Group. Registers itself, renders an optional heading, and hides when
|
|
2
|
+
// no member item matches the search.
|
|
3
|
+
import { useLayoutEffect, useMemo, useRef } from 'octane';
|
|
4
|
+
import { GroupContext, useCommand } from '../context.tsrx';
|
|
5
|
+
import { useCmdk, useId, useValue } from '../hooks.tsrx';
|
|
6
|
+
import type { GroupProps } from '../types';
|
|
7
|
+
|
|
8
|
+
export function Group(props: GroupProps) @{
|
|
9
|
+
const { heading, children, forceMount, ref: _ref, ...etc } = props;
|
|
10
|
+
const id = useId();
|
|
11
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
12
|
+
const headingRef = useRef<HTMLDivElement | null>(null);
|
|
13
|
+
const headingId = useId();
|
|
14
|
+
const context = useCommand()!;
|
|
15
|
+
const render = useCmdk(
|
|
16
|
+
(s) => forceMount
|
|
17
|
+
? true
|
|
18
|
+
: context.filter() === false
|
|
19
|
+
? true
|
|
20
|
+
: !s.search
|
|
21
|
+
? true
|
|
22
|
+
: s.filtered.groups.has(id),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
useLayoutEffect(() => {
|
|
26
|
+
return context.group(id);
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
// Value derives from the `value` prop or the heading's textContent.
|
|
30
|
+
// `aliases` is passed explicitly (groups have no keywords): octane compiles a
|
|
31
|
+
// custom-hook call as `withSlot(sym, hook, ...args, sym)`, so an OMITTED
|
|
32
|
+
// optional trailing parameter receives the slot symbol instead of its default
|
|
33
|
+
// (`= []` only fires on `undefined`). See useValue in ../hooks.tsrx.
|
|
34
|
+
useValue(id, ref, [props.value, headingRef], []);
|
|
35
|
+
|
|
36
|
+
const contextValue = useMemo(() => ({ id, forceMount }), [forceMount]);
|
|
37
|
+
|
|
38
|
+
<div
|
|
39
|
+
ref={[ref, props.ref]}
|
|
40
|
+
{...etc}
|
|
41
|
+
cmdk-group=""
|
|
42
|
+
role="presentation"
|
|
43
|
+
hidden={render ? undefined : true}
|
|
44
|
+
>
|
|
45
|
+
@if (heading) {
|
|
46
|
+
<div ref={headingRef} cmdk-group-heading="" aria-hidden={true} id={headingId}>{heading}</div>
|
|
47
|
+
}
|
|
48
|
+
<div cmdk-group-items="" role="group" aria-labelledby={heading ? headingId : undefined}>
|
|
49
|
+
<GroupContext.Provider value={contextValue}>{children}</GroupContext.Provider>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Command.Input. Drives the search from the native `onInput` event (the public
|
|
2
|
+
// `onValueChange` API is unchanged); supports controlled `value`.
|
|
3
|
+
import { useEffect } from 'octane';
|
|
4
|
+
import { useCommand, useStore } from '../context.tsrx';
|
|
5
|
+
import { useCmdk } from '../hooks.tsrx';
|
|
6
|
+
import type { InputProps } from '../types';
|
|
7
|
+
|
|
8
|
+
export function Input(props: InputProps) @{
|
|
9
|
+
const { onValueChange, ref: _ref, ...etc } = props;
|
|
10
|
+
const isControlled = props.value != null;
|
|
11
|
+
const store = useStore()!;
|
|
12
|
+
const search = useCmdk((s) => s.search);
|
|
13
|
+
const selectedItemId = useCmdk((s) => s.selectedItemId);
|
|
14
|
+
const context = useCommand()!;
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (props.value != null) {
|
|
18
|
+
store.setState('search', props.value);
|
|
19
|
+
}
|
|
20
|
+
}, [props.value]);
|
|
21
|
+
|
|
22
|
+
<input
|
|
23
|
+
ref={props.ref}
|
|
24
|
+
{...etc}
|
|
25
|
+
cmdk-input=""
|
|
26
|
+
autoComplete="off"
|
|
27
|
+
autoCorrect="off"
|
|
28
|
+
spellCheck={false}
|
|
29
|
+
aria-autocomplete="list"
|
|
30
|
+
role="combobox"
|
|
31
|
+
aria-expanded={true}
|
|
32
|
+
aria-controls={context.listId}
|
|
33
|
+
aria-labelledby={context.labelId}
|
|
34
|
+
aria-activedescendant={selectedItemId}
|
|
35
|
+
id={context.inputId}
|
|
36
|
+
type="text"
|
|
37
|
+
value={isControlled ? props.value : search}
|
|
38
|
+
onInput={(event) => {
|
|
39
|
+
const next = event.currentTarget.value;
|
|
40
|
+
if (!isControlled) store.setState('search', next);
|
|
41
|
+
onValueChange?.(next);
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Command.Item. Registers itself, tracks selection, and fires onSelect via the
|
|
2
|
+
// native cmdk select event (dispatched by the root's Enter handler or a click).
|
|
3
|
+
import { useContext, useEffect, useLayoutEffect, useRef } from 'octane';
|
|
4
|
+
import { GroupContext, useCommand, useStore } from '../context.tsrx';
|
|
5
|
+
import { useAsRef, useCmdk, useId, useValue } from '../hooks.tsrx';
|
|
6
|
+
import { SELECT_EVENT } from '../lib/constants';
|
|
7
|
+
import type { ItemProps } from '../types';
|
|
8
|
+
|
|
9
|
+
export function Item(props: ItemProps) @{
|
|
10
|
+
const id = useId();
|
|
11
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
12
|
+
const groupContext = useContext(GroupContext);
|
|
13
|
+
const context = useCommand()!;
|
|
14
|
+
const propsRef = useAsRef(props);
|
|
15
|
+
const forceMount = propsRef.current?.forceMount ?? groupContext?.forceMount;
|
|
16
|
+
|
|
17
|
+
useLayoutEffect(() => {
|
|
18
|
+
// A force-mounted item is exempt from filtering, so it is registered on the
|
|
19
|
+
// force-mount path instead — it still has to be counted somewhere, or Empty
|
|
20
|
+
// renders "no results" while this item is on screen.
|
|
21
|
+
return forceMount ? context.forceItem(id) : context.item(id, groupContext?.id);
|
|
22
|
+
}, [forceMount]);
|
|
23
|
+
|
|
24
|
+
// deps: the provided `value` prop, else the rendered element's textContent
|
|
25
|
+
// (via `ref`). cmdk's string-child inspection is dropped — octane's compiled
|
|
26
|
+
// children are opaque, and the textContent fallback covers the same case.
|
|
27
|
+
const value = useValue(id, ref, [props.value, ref], props.keywords);
|
|
28
|
+
|
|
29
|
+
const store = useStore()!;
|
|
30
|
+
const selected = useCmdk((s) => Boolean(s.value && s.value === value.current));
|
|
31
|
+
const render = useCmdk(
|
|
32
|
+
(s) => forceMount
|
|
33
|
+
? true
|
|
34
|
+
: context.filter() === false
|
|
35
|
+
? true
|
|
36
|
+
: !s.search
|
|
37
|
+
? true
|
|
38
|
+
: // An item with no entry in `filtered.items` has never been
|
|
39
|
+
// scored — that is not the same as having scored zero, and
|
|
40
|
+
// treating it as zero deadlocks: the item renders null, so
|
|
41
|
+
// `ref.current` stays null, so `useValue` has no textContent to
|
|
42
|
+
// infer from, so it registers an empty value, which scores zero
|
|
43
|
+
// forever. Upstream cannot reach this because it reads the value
|
|
44
|
+
// out of `props.children` synchronously; octane's compiled
|
|
45
|
+
// children are opaque, so the first render IS the registration.
|
|
46
|
+
// Rendering unscored items lets that first pass happen; the very
|
|
47
|
+
// next filter pass hides it if it genuinely does not match.
|
|
48
|
+
s.filtered.items.has(id)
|
|
49
|
+
? (s.filtered.items.get(id) ?? 0) > 0
|
|
50
|
+
: true,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const element = ref.current;
|
|
55
|
+
if (!element || props.disabled) return;
|
|
56
|
+
element.addEventListener(SELECT_EVENT, onSelect);
|
|
57
|
+
return () => element.removeEventListener(SELECT_EVENT, onSelect);
|
|
58
|
+
}, [render, props.onSelect, props.disabled]);
|
|
59
|
+
|
|
60
|
+
function onSelect() {
|
|
61
|
+
select();
|
|
62
|
+
propsRef.current.onSelect?.(value.current ?? '');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function select() {
|
|
66
|
+
store.setState('value', value.current ?? '', true);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!render) return null;
|
|
70
|
+
|
|
71
|
+
const {
|
|
72
|
+
disabled,
|
|
73
|
+
value: _value,
|
|
74
|
+
onSelect: _onSelect,
|
|
75
|
+
forceMount: _forceMount,
|
|
76
|
+
keywords: _keywords,
|
|
77
|
+
children,
|
|
78
|
+
ref: _ref,
|
|
79
|
+
...etc
|
|
80
|
+
} = props;
|
|
81
|
+
|
|
82
|
+
<div
|
|
83
|
+
ref={[ref, props.ref]}
|
|
84
|
+
{...etc}
|
|
85
|
+
id={id}
|
|
86
|
+
cmdk-item=""
|
|
87
|
+
role="option"
|
|
88
|
+
aria-disabled={Boolean(disabled)}
|
|
89
|
+
aria-selected={Boolean(selected)}
|
|
90
|
+
data-disabled={Boolean(disabled)}
|
|
91
|
+
data-selected={Boolean(selected)}
|
|
92
|
+
onPointerMove={disabled || context.getDisablePointerSelection() ? undefined : select}
|
|
93
|
+
onClick={disabled ? undefined : onSelect}
|
|
94
|
+
>{children}</div>
|
|
95
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Command.List. The listbox container; wires the inner sizer to the command
|
|
2
|
+
// context so the root can query items within it, and keeps the
|
|
3
|
+
// `--cmdk-list-height` custom property in sync with the sizer's height.
|
|
4
|
+
import { useEffect, useRef } from 'octane';
|
|
5
|
+
import { useCommand } from '../context.tsrx';
|
|
6
|
+
import { useCmdk } from '../hooks.tsrx';
|
|
7
|
+
import type { ListProps } from '../types';
|
|
8
|
+
|
|
9
|
+
export function List(props: ListProps) @{
|
|
10
|
+
const { children, label = 'Suggestions', ref: _ref, ...etc } = props;
|
|
11
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
12
|
+
const height = useRef<HTMLDivElement | null>(null);
|
|
13
|
+
const selectedItemId = useCmdk((s) => s.selectedItemId);
|
|
14
|
+
const context = useCommand()!;
|
|
15
|
+
|
|
16
|
+
// Mirror the sizer's height onto `--cmdk-list-height` so consumers can
|
|
17
|
+
// animate the list as results change. Guarded on ResizeObserver: it does not
|
|
18
|
+
// exist during SSR or in jsdom, where the property is simply never set.
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const el = height.current;
|
|
21
|
+
const wrapper = ref.current;
|
|
22
|
+
if (!el || !wrapper || typeof ResizeObserver === 'undefined') return;
|
|
23
|
+
|
|
24
|
+
let animationFrame = 0;
|
|
25
|
+
const observer = new ResizeObserver(() => {
|
|
26
|
+
animationFrame = requestAnimationFrame(() => {
|
|
27
|
+
wrapper.style.setProperty('--cmdk-list-height', el.offsetHeight.toFixed(1) + 'px');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
observer.observe(el);
|
|
31
|
+
|
|
32
|
+
return () => {
|
|
33
|
+
cancelAnimationFrame(animationFrame);
|
|
34
|
+
observer.unobserve(el);
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
<div
|
|
39
|
+
ref={[ref, props.ref]}
|
|
40
|
+
{...etc}
|
|
41
|
+
cmdk-list=""
|
|
42
|
+
role="listbox"
|
|
43
|
+
tabIndex={-1}
|
|
44
|
+
aria-activedescendant={selectedItemId}
|
|
45
|
+
aria-label={label}
|
|
46
|
+
id={context.listId}
|
|
47
|
+
>
|
|
48
|
+
<div ref={[height, context.listInnerRef]} cmdk-list-sizer="">{children}</div>
|
|
49
|
+
</div>
|
|
50
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Command.Loading. A progressbar to render while async options load.
|
|
2
|
+
import type { LoadingProps } from '../types';
|
|
3
|
+
|
|
4
|
+
export function Loading(props: LoadingProps) @{
|
|
5
|
+
const { progress, children, label = 'Loading...', ref: _ref, ...etc } = props;
|
|
6
|
+
|
|
7
|
+
<div
|
|
8
|
+
ref={props.ref}
|
|
9
|
+
{...etc}
|
|
10
|
+
cmdk-loading=""
|
|
11
|
+
role="progressbar"
|
|
12
|
+
aria-valuenow={progress}
|
|
13
|
+
aria-valuemin={0}
|
|
14
|
+
aria-valuemax={100}
|
|
15
|
+
aria-label={label}
|
|
16
|
+
>
|
|
17
|
+
<div aria-hidden={true}>{children}</div>
|
|
18
|
+
</div>
|
|
19
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Command.Separator. Shown when the search is empty (or always, with
|
|
2
|
+
// `alwaysRender`); removed while searching.
|
|
3
|
+
import { useRef } from 'octane';
|
|
4
|
+
import { useCmdk } from '../hooks.tsrx';
|
|
5
|
+
import type { SeparatorProps } from '../types';
|
|
6
|
+
|
|
7
|
+
export function Separator(props: SeparatorProps) @{
|
|
8
|
+
const { alwaysRender, ref: _ref, ...etc } = props;
|
|
9
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
10
|
+
const render = useCmdk((s) => !s.search);
|
|
11
|
+
|
|
12
|
+
if (!alwaysRender && !render) return null;
|
|
13
|
+
<div ref={[ref, props.ref]} {...etc} cmdk-separator="" role="separator" />
|
|
14
|
+
}
|
package/src/context.tsrx
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The three cmdk contexts and their accessors. `useContext` is context-identity
|
|
2
|
+
// keyed (not compiler-slot keyed), so these thin wrappers are safe to share.
|
|
3
|
+
import { createContext, useContext } from 'octane';
|
|
4
|
+
import type { Context, Group, Store } from './types';
|
|
5
|
+
|
|
6
|
+
export const CommandContext = createContext<Context | null>(null);
|
|
7
|
+
export const useCommand = () => useContext(CommandContext);
|
|
8
|
+
|
|
9
|
+
export const StoreContext = createContext<Store | null>(null);
|
|
10
|
+
export const useStore = () => useContext(StoreContext);
|
|
11
|
+
|
|
12
|
+
export const GroupContext = createContext<Group | null>(null);
|
package/src/filter.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Framework-free default filter. Kept out of the .tsrx component module so the
|
|
2
|
+
// scorer/filter can be imported without pulling in the octane renderer (the
|
|
3
|
+
// unit tests run in a plain node environment).
|
|
4
|
+
import { commandScore } from './command-score';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The default scoring filter used by `Command` when no custom `filter` is
|
|
8
|
+
* provided. Mirrors cmdk's `defaultFilter`: a positive score means the value
|
|
9
|
+
* matches the search; `0` means no match. `keywords` (aliases) are folded into
|
|
10
|
+
* the scored string.
|
|
11
|
+
*/
|
|
12
|
+
export function defaultFilter(value: string, search: string, keywords?: string[]): number {
|
|
13
|
+
return commandScore(value, search, keywords ?? []);
|
|
14
|
+
}
|
package/src/hooks.tsrx
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Custom hooks shared across the cmdk components. Kept in a `.tsrx` module so
|
|
2
|
+
// octane's compiler owns their hook slots (the same pattern sonner uses for its
|
|
3
|
+
// hooks.tsrx); consumers import and call them like any hook.
|
|
4
|
+
import {
|
|
5
|
+
useId as octaneUseId,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
useSyncExternalStore,
|
|
10
|
+
} from 'octane';
|
|
11
|
+
import { useCommand, useStore } from './context.tsrx';
|
|
12
|
+
import { VALUE_ATTR } from './lib/constants';
|
|
13
|
+
import type { State } from './types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* cmdk takes its ids from `@radix-ui/react-id`, which prefixes the framework's
|
|
17
|
+
* `useId` with `radix-` so they read as Radix-owned and never collide with app
|
|
18
|
+
* ids. Match that here (rather than octane's bare `useId`) so the rendered
|
|
19
|
+
* `id`/`aria-*`/`for` wiring is shape-compatible with upstream — the
|
|
20
|
+
* differential suite compares it.
|
|
21
|
+
*/
|
|
22
|
+
export function useId(): string {
|
|
23
|
+
return `radix-${octaneUseId()}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A ref that always holds the latest value (updated in a layout effect). */
|
|
27
|
+
export function useAsRef<T>(data: T) {
|
|
28
|
+
const ref = useRef<T>(data);
|
|
29
|
+
useLayoutEffect(() => {
|
|
30
|
+
ref.current = data;
|
|
31
|
+
});
|
|
32
|
+
return ref;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A ref whose value is computed lazily on first render. */
|
|
36
|
+
export function useLazyRef<T>(fn: () => T) {
|
|
37
|
+
const ref = useRef<T | undefined>(undefined);
|
|
38
|
+
if (ref.current === undefined) {
|
|
39
|
+
ref.current = fn();
|
|
40
|
+
}
|
|
41
|
+
return ref as { current: T };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Run a selector against the store state. */
|
|
45
|
+
export function useCmdk<T>(selector: (state: State) => T): T {
|
|
46
|
+
const store = useStore()!;
|
|
47
|
+
const cb = () => selector(store.snapshot());
|
|
48
|
+
return useSyncExternalStore(store.subscribe, cb, cb);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Registers an item/group's value: from the provided `value` prop or the
|
|
53
|
+
* rendered element's `textContent` (cmdk's string-child inspection is dropped
|
|
54
|
+
* because octane's compiled children are opaque). Returns the value ref.
|
|
55
|
+
*
|
|
56
|
+
* NOTE on `aliases`: octane compiles a custom-hook call as
|
|
57
|
+
* `withSlot(sym, hook, ...args, sym)`, so the trailing slot symbol becomes an
|
|
58
|
+
* extra argument. A caller that OMITS this optional trailing parameter receives
|
|
59
|
+
* that symbol instead of the `= []` default (which only fires on `undefined`).
|
|
60
|
+
* Callers must therefore pass `aliases` explicitly, and the read below is
|
|
61
|
+
* defensive so a missed call site degrades to "no keywords" rather than
|
|
62
|
+
* throwing mid-effect (which silently aborts value registration).
|
|
63
|
+
*/
|
|
64
|
+
export function useValue(
|
|
65
|
+
id: string,
|
|
66
|
+
ref: { current: HTMLElement | null },
|
|
67
|
+
deps: (string | undefined | { current: HTMLElement | null })[],
|
|
68
|
+
aliases: string[] = [],
|
|
69
|
+
) {
|
|
70
|
+
const valueRef = useRef<string | undefined>(undefined);
|
|
71
|
+
const context = useCommand()!;
|
|
72
|
+
|
|
73
|
+
useLayoutEffect(() => {
|
|
74
|
+
const value = (() => {
|
|
75
|
+
for (const part of deps) {
|
|
76
|
+
if (typeof part === 'string') return part.trim();
|
|
77
|
+
if (part && typeof part === 'object' && 'current' in part) {
|
|
78
|
+
if (part.current) return part.current.textContent?.trim();
|
|
79
|
+
return valueRef.current;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
})();
|
|
84
|
+
|
|
85
|
+
const keywords =
|
|
86
|
+
Array.isArray(aliases) ? aliases.map((alias) => alias.trim()) : [];
|
|
87
|
+
context.value(id, value, keywords);
|
|
88
|
+
ref.current?.setAttribute(VALUE_ATTR, value ?? '');
|
|
89
|
+
valueRef.current = value;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return valueRef;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Imperatively run a function on the next layout-effect cycle. */
|
|
96
|
+
export function useScheduleLayoutEffect() {
|
|
97
|
+
const [s, ss] = useState<object | undefined>(undefined);
|
|
98
|
+
const fns = useLazyRef(() => new Map<string | number, () => void>());
|
|
99
|
+
|
|
100
|
+
useLayoutEffect(() => {
|
|
101
|
+
// Snapshot and clear BEFORE running: a scheduled callback may schedule more
|
|
102
|
+
// work (e.g. selectFirstItem → setState('value') → schedule(...)), and those
|
|
103
|
+
// nested entries must survive into the next flush rather than be wiped by a
|
|
104
|
+
// reset that runs after the callbacks. Each callback is isolated so one
|
|
105
|
+
// throwing (e.g. scrollIntoView) can't drop the callbacks queued after it.
|
|
106
|
+
const pending = [...fns.current.values()];
|
|
107
|
+
fns.current = new Map();
|
|
108
|
+
for (const f of pending) {
|
|
109
|
+
try {
|
|
110
|
+
f();
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// Isolate, but never swallow. A scheduled callback can run user code
|
|
113
|
+
// (selectFirstItem -> setState('value') -> onValueChange), so a failure
|
|
114
|
+
// here must stay visible; report it the same way octane reports an
|
|
115
|
+
// exception raised inside an effect, and keep the siblings running.
|
|
116
|
+
console.error(error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}, [s]);
|
|
120
|
+
|
|
121
|
+
return (id: string | number, cb: () => void) => {
|
|
122
|
+
fns.current.set(id, cb);
|
|
123
|
+
ss({});
|
|
124
|
+
};
|
|
125
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @octanejs/cmdk — a port of cmdk@1.1.1 (dip/cmdk) onto the octane renderer.
|
|
2
|
+
// See docs/cmdk-port-plan.md for the phased plan and supported surface.
|
|
3
|
+
//
|
|
4
|
+
// The renderer is authored in `.tsrx` so octane's compiler owns the component
|
|
5
|
+
// templates and hook slots; the scorer/filter stay framework-free.
|
|
6
|
+
export {
|
|
7
|
+
Command,
|
|
8
|
+
CommandRoot,
|
|
9
|
+
CommandItem,
|
|
10
|
+
CommandInput,
|
|
11
|
+
CommandList,
|
|
12
|
+
CommandGroup,
|
|
13
|
+
CommandSeparator,
|
|
14
|
+
CommandDialog,
|
|
15
|
+
CommandEmpty,
|
|
16
|
+
CommandLoading,
|
|
17
|
+
useCommandState,
|
|
18
|
+
} from './cmdk.tsrx';
|
|
19
|
+
export { defaultFilter } from './filter';
|
|
20
|
+
export type {
|
|
21
|
+
CommandProps,
|
|
22
|
+
ItemProps,
|
|
23
|
+
InputProps,
|
|
24
|
+
ListProps,
|
|
25
|
+
GroupProps,
|
|
26
|
+
SeparatorProps,
|
|
27
|
+
DialogProps,
|
|
28
|
+
EmptyProps,
|
|
29
|
+
LoadingProps,
|
|
30
|
+
CommandFilter,
|
|
31
|
+
} from './types';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The cmdk DOM contract: attribute selectors consumers style against and the
|
|
2
|
+
// port queries, the native selection event name, the value attribute, and the
|
|
3
|
+
// screen-reader-only style for the hidden accessibility label.
|
|
4
|
+
|
|
5
|
+
export const GROUP_SELECTOR = `[cmdk-group=""]`;
|
|
6
|
+
export const GROUP_ITEMS_SELECTOR = `[cmdk-group-items=""]`;
|
|
7
|
+
export const GROUP_HEADING_SELECTOR = `[cmdk-group-heading=""]`;
|
|
8
|
+
export const ITEM_SELECTOR = `[cmdk-item=""]`;
|
|
9
|
+
export const VALID_ITEM_SELECTOR = `${ITEM_SELECTOR}:not([aria-disabled="true"])`;
|
|
10
|
+
export const SELECT_EVENT = `cmdk-item-select`;
|
|
11
|
+
export const VALUE_ATTR = `data-value`;
|
|
12
|
+
|
|
13
|
+
export const srOnlyStyles = {
|
|
14
|
+
position: 'absolute',
|
|
15
|
+
width: '1px',
|
|
16
|
+
height: '1px',
|
|
17
|
+
padding: '0',
|
|
18
|
+
margin: '-1px',
|
|
19
|
+
overflow: 'hidden',
|
|
20
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
21
|
+
whiteSpace: 'nowrap',
|
|
22
|
+
borderWidth: '0',
|
|
23
|
+
} as const;
|