panelui-web 0.0.0-stage → 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.
Files changed (32) hide show
  1. package/README.md +43 -2
  2. package/dist/components/bar-picker/index.d.ts +61 -0
  3. package/dist/components/bar-picker/index.js +139 -0
  4. package/dist/components/command-dock/index.d.ts +118 -0
  5. package/dist/components/command-dock/index.js +239 -0
  6. package/dist/components/day-grid/index.d.ts +87 -0
  7. package/dist/components/day-grid/index.js +251 -0
  8. package/dist/components/feedback/index.d.ts +135 -0
  9. package/dist/components/feedback/index.js +283 -0
  10. package/dist/components/flow/flow-paths.d.ts +94 -0
  11. package/dist/components/flow/flow-paths.js +270 -0
  12. package/dist/components/flow/index.d.ts +235 -0
  13. package/dist/components/flow/index.js +597 -0
  14. package/dist/components/kpi/index.d.ts +211 -0
  15. package/dist/components/kpi/index.js +243 -0
  16. package/dist/components/lane-board/index.d.ts +125 -0
  17. package/dist/components/lane-board/index.js +691 -0
  18. package/dist/components/planet/index.d.ts +132 -0
  19. package/dist/components/planet/index.js +653 -0
  20. package/dist/components/planet/land.d.ts +8 -0
  21. package/dist/components/planet/land.js +122 -0
  22. package/dist/components/range-meter/index.d.ts +70 -0
  23. package/dist/components/range-meter/index.js +170 -0
  24. package/dist/components/rolling-number/index.d.ts +46 -0
  25. package/dist/components/rolling-number/index.js +81 -0
  26. package/dist/components/tilt-card/index.d.ts +60 -0
  27. package/dist/components/tilt-card/index.js +114 -0
  28. package/dist/index.d.ts +12 -0
  29. package/dist/index.js +12 -0
  30. package/dist/lib/cn.d.ts +3 -0
  31. package/dist/lib/cn.js +6 -0
  32. package/package.json +72 -4
package/README.md CHANGED
@@ -1,3 +1,44 @@
1
- # Temporary Holding Version
1
+ # panelui-web
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ PanelUI for the web: animated, data-rich React components, styled with Tailwind CSS on the
4
+ tokens a shadcn project already defines.
5
+
6
+ Documentation: [panelui.dev/docs/web](https://panelui.dev/docs/web)
7
+
8
+ ## Two ways to use it
9
+
10
+ **Copy a component into your project** with the shadcn CLI. The source lands in
11
+ `components/panelui/` and is yours to edit:
12
+
13
+ ```bash
14
+ npx shadcn@latest add https://panelui.dev/r/web/planet.json
15
+ ```
16
+
17
+ **Or install the package** and import from it:
18
+
19
+ ```bash
20
+ npm install panelui-web
21
+ ```
22
+
23
+ ```css
24
+ /* app/globals.css — let Tailwind see the classes the package uses */
25
+ @import "tailwindcss";
26
+ @source "../node_modules/panelui-web/dist";
27
+ ```
28
+
29
+ ```tsx
30
+ import { Planet } from 'panelui-web/planet';
31
+ ```
32
+
33
+ Both read the same source, so a component looks and behaves the same either way.
34
+
35
+ ## Requirements
36
+
37
+ - React 18 or newer
38
+ - Tailwind CSS 4
39
+ - The shadcn theme tokens (`--background`, `--foreground`, `--card`, `--muted`, `--border`,
40
+ `--primary` and their `-foreground` pairs). `npx shadcn@latest init` writes them.
41
+
42
+ ## License
43
+
44
+ MIT
@@ -0,0 +1,61 @@
1
+ /**
2
+ * BarPicker — a short run of values as columns of lights, one column picked.
3
+ *
4
+ * ```tsx
5
+ * <BarPicker data={week} value={day} onValueChange={setDay} />
6
+ * ```
7
+ *
8
+ * ## A column is a stack of lights
9
+ *
10
+ * Each value is drawn as a column of segments, lit from the bottom up to the
11
+ * value, the way a level meter shows loudness. The unlit segments stay as
12
+ * faint outlines of themselves, so every column is the same height and how
13
+ * full it is can be judged against its own top without an axis.
14
+ *
15
+ * ## The picked column is the bright one
16
+ *
17
+ * It is lit at full strength with a glow on its highest segment, and it is
18
+ * wider than the others. The rest are the same colour, held back. Picking
19
+ * another column lights it from the bottom, one segment after the next.
20
+ *
21
+ * ## A choice, not a chart
22
+ *
23
+ * It behaves like a row of radio buttons. Exactly one column is picked, and
24
+ * the arrow keys walk along the row. The figure for the picked column is
25
+ * yours to show beside it, large: a column of eight lights cannot be read to
26
+ * the unit, but one number can.
27
+ */
28
+ import { type ComponentProps, type ReactNode } from 'react';
29
+ export interface BarPickerBar {
30
+ /** Written under the column: "Mon", "W3". Keep it to a few characters. */
31
+ label: ReactNode;
32
+ value: number;
33
+ /** The long form, for a screen reader: "Monday". Defaults to the label. */
34
+ name?: string;
35
+ }
36
+ /** Which of the theme's roles the lights are drawn in. */
37
+ export type BarPickerColor = 'primary' | 'info' | 'success' | 'warning' | 'destructive';
38
+ export interface BarPickerProps extends Omit<ComponentProps<'div'>, 'children' | 'onChange' | 'defaultValue' | 'color'> {
39
+ /** The columns, left to right. */
40
+ data: BarPickerBar[];
41
+ /** The value a fully lit column stands for. Defaults to the largest in `data`. */
42
+ max?: number;
43
+ /** The index of the picked column, when the caller holds it. */
44
+ value?: number;
45
+ /** The index picked to begin with, when the caller does not hold it. */
46
+ defaultValue?: number;
47
+ /** Called with the index of a column when it is picked. */
48
+ onValueChange?: (index: number) => void;
49
+ /** How many lights a column is made of. */
50
+ segments?: number;
51
+ /** Which of the theme's roles the lights are drawn in. */
52
+ color?: BarPickerColor;
53
+ /** Writes a value for a screen reader: "17 deploys". */
54
+ formatValue?: (value: number) => string;
55
+ /** What a screen reader calls the row. */
56
+ label?: string;
57
+ }
58
+ export declare function BarPicker({ data, max, value, defaultValue, onValueChange, segments, color, formatValue, label, className, ...props }: BarPickerProps): import("react").JSX.Element;
59
+ export declare namespace BarPicker {
60
+ var displayName: string;
61
+ }
@@ -0,0 +1,139 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * BarPicker — a short run of values as columns of lights, one column picked.
5
+ *
6
+ * ```tsx
7
+ * <BarPicker data={week} value={day} onValueChange={setDay} />
8
+ * ```
9
+ *
10
+ * ## A column is a stack of lights
11
+ *
12
+ * Each value is drawn as a column of segments, lit from the bottom up to the
13
+ * value, the way a level meter shows loudness. The unlit segments stay as
14
+ * faint outlines of themselves, so every column is the same height and how
15
+ * full it is can be judged against its own top without an axis.
16
+ *
17
+ * ## The picked column is the bright one
18
+ *
19
+ * It is lit at full strength with a glow on its highest segment, and it is
20
+ * wider than the others. The rest are the same colour, held back. Picking
21
+ * another column lights it from the bottom, one segment after the next.
22
+ *
23
+ * ## A choice, not a chart
24
+ *
25
+ * It behaves like a row of radio buttons. Exactly one column is picked, and
26
+ * the arrow keys walk along the row. The figure for the picked column is
27
+ * yours to show beside it, large: a column of eight lights cannot be read to
28
+ * the unit, but one number can.
29
+ */
30
+ import { useEffect, useRef, useState, } from 'react';
31
+ import { tv } from 'tailwind-variants';
32
+ const barPickerVariants = tv({
33
+ slots: {
34
+ root: 'flex w-full items-end gap-1.5',
35
+ column: 'group flex min-w-0 cursor-pointer flex-col items-stretch gap-2.5 rounded-lg outline-none transition-[flex-grow] duration-500 ease-[cubic-bezier(0.34,1.4,0.64,1)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background motion-reduce:transition-none',
36
+ // Stacked from the bottom, so the first segment in the markup is the
37
+ // lowest light.
38
+ stack: 'flex h-28 flex-col-reverse gap-[3px]',
39
+ segment: 'min-h-0 flex-1 rounded-[5px] transition-[background-color,box-shadow,opacity] duration-300 motion-reduce:transition-none',
40
+ label: 'truncate text-center text-xs text-muted-foreground transition-colors',
41
+ },
42
+ variants: {
43
+ color: {
44
+ primary: { root: '[--pu-color:var(--primary)]' },
45
+ info: { root: '[--pu-color:var(--info)]' },
46
+ success: { root: '[--pu-color:var(--success)]' },
47
+ warning: { root: '[--pu-color:var(--warning)]' },
48
+ destructive: { root: '[--pu-color:var(--destructive)]' },
49
+ },
50
+ selected: {
51
+ true: { column: 'flex-[1.5]', label: 'font-medium text-foreground' },
52
+ false: { column: 'flex-1' },
53
+ },
54
+ /** What one segment is doing. */
55
+ light: {
56
+ off: { segment: 'bg-muted' },
57
+ // Lit in a column that is not picked: the colour, held back.
58
+ dim: {
59
+ segment: 'bg-[color-mix(in_oklab,var(--pu-color)_26%,transparent)] group-hover:bg-[color-mix(in_oklab,var(--pu-color)_48%,transparent)]',
60
+ },
61
+ on: { segment: 'bg-[var(--pu-color)]' },
62
+ // The highest light of the picked column.
63
+ peak: {
64
+ segment: 'bg-[var(--pu-color)] shadow-[0_0_16px_color-mix(in_oklab,var(--pu-color)_65%,transparent)]',
65
+ },
66
+ },
67
+ },
68
+ });
69
+ /** Milliseconds between one light of a column coming on and the next. */
70
+ const STAGGER = 32;
71
+ export function BarPicker({ data, max, value, defaultValue = 0, onValueChange, segments = 8, color = 'primary', formatValue = (bar) => String(bar), label = 'Values', className, ...props }) {
72
+ const slots = barPickerVariants({ color });
73
+ const [internal, setInternal] = useState(defaultValue);
74
+ const picked = clampIndex(value ?? internal, data.length);
75
+ const refs = useRef([]);
76
+ // The columns start dark and light up once mounted, so they are seen
77
+ // arriving.
78
+ const [lit, setLit] = useState(false);
79
+ useEffect(() => {
80
+ const frame = requestAnimationFrame(() => setLit(true));
81
+ return () => cancelAnimationFrame(frame);
82
+ }, []);
83
+ const count = Math.max(1, Math.round(segments));
84
+ const ceiling = max ?? Math.max(1, ...data.map((bar) => bar.value));
85
+ const pick = (index) => {
86
+ if (value === undefined)
87
+ setInternal(index);
88
+ onValueChange?.(index);
89
+ };
90
+ const onKeyDown = (event, index) => {
91
+ const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';
92
+ const forward = rtl ? -1 : 1;
93
+ const moves = {
94
+ ArrowLeft: index - forward,
95
+ ArrowRight: index + forward,
96
+ ArrowDown: index - 1,
97
+ ArrowUp: index + 1,
98
+ Home: 0,
99
+ End: data.length - 1,
100
+ };
101
+ const target = moves[event.key];
102
+ if (target === undefined)
103
+ return;
104
+ event.preventDefault();
105
+ // Round the ends, as a radio group does.
106
+ const next = (target + data.length) % data.length;
107
+ refs.current[next]?.focus();
108
+ pick(next);
109
+ };
110
+ return (_jsx("div", { role: "radiogroup", "aria-label": label, className: slots.root({ className }), ...props, children: data.map((bar, index) => {
111
+ const selected = index === picked;
112
+ const part = barPickerVariants({ selected });
113
+ const name = bar.name ?? (typeof bar.label === 'string' ? bar.label : `Bar ${index + 1}`);
114
+ // How many lights the value reaches. Any value above zero lights at
115
+ // least one, so a small value is a mark and not an empty column.
116
+ const share = Math.min(1, Math.max(0, bar.value / ceiling));
117
+ const reached = !lit || bar.value <= 0 ? 0 : Math.max(1, Math.round(share * count));
118
+ return (_jsxs("button", { ref: (node) => {
119
+ refs.current[index] = node;
120
+ }, type: "button", role: "radio", "aria-checked": selected, "aria-label": `${name}: ${formatValue(bar.value)}`, tabIndex: selected ? 0 : -1, onClick: () => pick(index), onKeyDown: (event) => onKeyDown(event, index), className: part.column(), children: [_jsx("span", { className: slots.stack(), children: Array.from({ length: count }, (_, level) => {
121
+ const on = level < reached;
122
+ const light = !on ? 'off' : !selected ? 'dim' : level === reached - 1 ? 'peak' : 'on';
123
+ return (_jsx("span", { className: barPickerVariants({ light }).segment(), style: {
124
+ // Bottom first, so a column is seen filling upwards.
125
+ transitionDelay: on ? `${level * STAGGER}ms` : undefined,
126
+ // The picked column brightens towards its top.
127
+ opacity: light === 'on'
128
+ ? Number((0.5 + 0.5 * ((level + 1) / reached)).toFixed(3))
129
+ : undefined,
130
+ } }, level));
131
+ }) }), _jsx("span", { "aria-hidden": "true", className: part.label(), children: bar.label })] }, index));
132
+ }) }));
133
+ }
134
+ BarPicker.displayName = 'BarPicker';
135
+ function clampIndex(index, length) {
136
+ if (length === 0)
137
+ return 0;
138
+ return Math.min(length - 1, Math.max(0, index));
139
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * CommandDock — a small button that opens into a place to ask for something,
3
+ * with the answer arriving under it a row at a time.
4
+ *
5
+ * ```tsx
6
+ * <CommandDock onSubmit={ask}>
7
+ * <CommandDock.Trigger>Ask</CommandDock.Trigger>
8
+ * <CommandDock.Panel>
9
+ * <CommandDock.Input placeholder="What do you want to find?" />
10
+ * <CommandDock.Results>
11
+ * {rows.map((row) => <CommandDock.Row key={row.id}>{row.text}</CommandDock.Row>)}
12
+ * </CommandDock.Results>
13
+ * </CommandDock.Panel>
14
+ * </CommandDock>
15
+ * ```
16
+ *
17
+ * ## One surface that opens
18
+ *
19
+ * The button and the panel are the same surface. Closed, it is a pill as
20
+ * wide as its label. Opened, it widens, the label fades into a field in the
21
+ * same place, and it grows downwards as rows arrive. Closing runs the same
22
+ * steps backwards: the rows fold up, then it narrows. Nothing appears from
23
+ * elsewhere, so it is clear what was pressed and where the answer belongs.
24
+ *
25
+ * ## It runs nothing
26
+ *
27
+ * The dock does not know where answers come from. `onSubmit` is handed what
28
+ * was typed, and the rows are whatever you render. A row that mounts slides
29
+ * in after the one before it, so rows added one by one read as an answer
30
+ * being written, whether they come from a search, a model or a list you
31
+ * already had.
32
+ *
33
+ * ## Closing
34
+ *
35
+ * Escape closes it and gives focus back to where it was, and so does a press
36
+ * anywhere outside it.
37
+ */
38
+ import { type ComponentProps, type ReactNode } from 'react';
39
+ export interface CommandDockProps extends Omit<ComponentProps<'div'>, 'onSubmit' | 'defaultValue'> {
40
+ /** Whether it is open, when the caller holds that. */
41
+ open?: boolean;
42
+ /** Whether it starts open, when the caller does not. */
43
+ defaultOpen?: boolean;
44
+ /** Called when it opens or closes. */
45
+ onOpenChange?: (open: boolean) => void;
46
+ /** What is typed in the field, when the caller holds it. */
47
+ value?: string;
48
+ /** Called with what is typed, each time it changes. */
49
+ onValueChange?: (value: string) => void;
50
+ /** Called with what was typed when it is sent. */
51
+ onSubmit?: (value: string) => void;
52
+ /** While true, the field cannot be sent again and `CommandDock.Status` is shown. */
53
+ busy?: boolean;
54
+ /** How wide it is while closed, as a CSS length. */
55
+ closedWidth?: string;
56
+ }
57
+ declare function CommandDockRoot({ open: controlledOpen, defaultOpen, onOpenChange, value: controlledValue, onValueChange, onSubmit, busy, closedWidth, className, style, children, ...props }: CommandDockProps): import("react").JSX.Element;
58
+ declare namespace CommandDockRoot {
59
+ var displayName: string;
60
+ }
61
+ export interface CommandDockTriggerProps extends ComponentProps<'button'> {
62
+ }
63
+ /** The closed form: a button that opens the dock. */
64
+ declare function CommandDockTrigger({ className, onClick, ...props }: CommandDockTriggerProps): import("react").JSX.Element;
65
+ declare namespace CommandDockTrigger {
66
+ var displayName: string;
67
+ }
68
+ export interface CommandDockPanelProps extends ComponentProps<'div'> {
69
+ }
70
+ /** The open form: the field, and the rows under it. */
71
+ declare function CommandDockPanel({ className, ...props }: CommandDockPanelProps): import("react").JSX.Element;
72
+ declare namespace CommandDockPanel {
73
+ var displayName: string;
74
+ }
75
+ export interface CommandDockInputProps extends Omit<ComponentProps<'input'>, 'value' | 'onChange' | 'type'> {
76
+ /** What a screen reader calls the send button. */
77
+ submitLabel?: string;
78
+ }
79
+ /** The field, with a button to send what is in it. Enter sends too. */
80
+ declare function CommandDockInput({ className, submitLabel, 'aria-label': ariaLabel, ...props }: CommandDockInputProps): import("react").JSX.Element;
81
+ declare namespace CommandDockInput {
82
+ var displayName: string;
83
+ }
84
+ export interface CommandDockResultsProps extends ComponentProps<'div'> {
85
+ /** What a screen reader calls the list of rows. */
86
+ label?: string;
87
+ }
88
+ /** Where the rows go. It takes no room until there is something in it. */
89
+ declare function CommandDockResults({ className, label, children, ...props }: CommandDockResultsProps): import("react").JSX.Element;
90
+ declare namespace CommandDockResults {
91
+ var displayName: string;
92
+ }
93
+ export interface CommandDockRowProps extends ComponentProps<'div'> {
94
+ /** Called when the row is pressed. Passing this makes the row a button. */
95
+ onSelect?: () => void;
96
+ }
97
+ /** One row of the answer. It slides in when it is added. */
98
+ declare function CommandDockRow({ className, onSelect, children, ...props }: CommandDockRowProps): import("react").JSX.Element;
99
+ declare namespace CommandDockRow {
100
+ var displayName: string;
101
+ }
102
+ export interface CommandDockStatusProps extends ComponentProps<'div'> {
103
+ children?: ReactNode;
104
+ }
105
+ /** Shown under the rows while `busy`: three dots, and words if you give it some. */
106
+ declare function CommandDockStatus({ className, children, ...props }: CommandDockStatusProps): import("react").JSX.Element;
107
+ declare namespace CommandDockStatus {
108
+ var displayName: string;
109
+ }
110
+ export declare const CommandDock: typeof CommandDockRoot & {
111
+ Trigger: typeof CommandDockTrigger;
112
+ Panel: typeof CommandDockPanel;
113
+ Input: typeof CommandDockInput;
114
+ Results: typeof CommandDockResults;
115
+ Row: typeof CommandDockRow;
116
+ Status: typeof CommandDockStatus;
117
+ };
118
+ export { CommandDockTrigger, CommandDockPanel, CommandDockInput, CommandDockResults, CommandDockRow, CommandDockStatus, };
@@ -0,0 +1,239 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * CommandDock — a small button that opens into a place to ask for something,
5
+ * with the answer arriving under it a row at a time.
6
+ *
7
+ * ```tsx
8
+ * <CommandDock onSubmit={ask}>
9
+ * <CommandDock.Trigger>Ask</CommandDock.Trigger>
10
+ * <CommandDock.Panel>
11
+ * <CommandDock.Input placeholder="What do you want to find?" />
12
+ * <CommandDock.Results>
13
+ * {rows.map((row) => <CommandDock.Row key={row.id}>{row.text}</CommandDock.Row>)}
14
+ * </CommandDock.Results>
15
+ * </CommandDock.Panel>
16
+ * </CommandDock>
17
+ * ```
18
+ *
19
+ * ## One surface that opens
20
+ *
21
+ * The button and the panel are the same surface. Closed, it is a pill as
22
+ * wide as its label. Opened, it widens, the label fades into a field in the
23
+ * same place, and it grows downwards as rows arrive. Closing runs the same
24
+ * steps backwards: the rows fold up, then it narrows. Nothing appears from
25
+ * elsewhere, so it is clear what was pressed and where the answer belongs.
26
+ *
27
+ * ## It runs nothing
28
+ *
29
+ * The dock does not know where answers come from. `onSubmit` is handed what
30
+ * was typed, and the rows are whatever you render. A row that mounts slides
31
+ * in after the one before it, so rows added one by one read as an answer
32
+ * being written, whether they come from a search, a model or a list you
33
+ * already had.
34
+ *
35
+ * ## Closing
36
+ *
37
+ * Escape closes it and gives focus back to where it was, and so does a press
38
+ * anywhere outside it.
39
+ */
40
+ import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from 'react';
41
+ import { tv } from 'tailwind-variants';
42
+ const commandDockVariants = tv({
43
+ slots: {
44
+ root: 'relative flex w-full justify-center',
45
+ /*
46
+ * The widths are both real lengths, so the change between them can be
47
+ * animated; `auto` cannot. The easing has no overshoot: a surface this
48
+ * size that bounces as it closes reads as a mistake.
49
+ */
50
+ surface: 'overflow-hidden border bg-card text-card-foreground shadow-sm transition-[width,border-radius,box-shadow] duration-[400ms] ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',
51
+ // The label keeps its row whether or not it can be seen, so the surface
52
+ // is never shorter than one row and never jumps as the two trade places.
53
+ trigger: 'flex h-11 w-full cursor-pointer items-center justify-center gap-2 whitespace-nowrap px-4 text-sm font-medium outline-none transition-[opacity,background-color] duration-200 hover:bg-accent focus-visible:bg-accent [&_svg]:size-4 [&_svg]:shrink-0',
54
+ // Pulled up over the label's row: the field sits exactly where the label
55
+ // was, and the two fade through each other.
56
+ panel: '-mt-11 flex flex-col',
57
+ form: 'flex h-11 items-center gap-2 pe-1.5 ps-4 transition-opacity duration-200 motion-reduce:transition-none',
58
+ input: 'h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
59
+ submit: 'inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-primary text-primary-foreground outline-none transition-[opacity,scale] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card active:scale-95 disabled:cursor-default disabled:opacity-30 [&_svg]:size-4',
60
+ // Two rows of a grid, the second going from no height to its own, which
61
+ // is how the panel grows to whatever the rows come to.
62
+ results: 'grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none',
63
+ resultsClip: 'min-h-0 overflow-hidden',
64
+ resultsInner: 'flex flex-col gap-0.5 border-t p-1.5',
65
+ row: 'flex items-center gap-3 rounded-xl px-2.5 py-2 text-sm transition-[opacity,translate] duration-300 ease-out starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground',
66
+ status: 'flex items-center gap-2 px-2.5 py-2 text-sm text-muted-foreground',
67
+ dots: 'flex gap-1',
68
+ dot: 'size-1 animate-pulse rounded-full bg-muted-foreground',
69
+ },
70
+ variants: {
71
+ /*
72
+ * Opening and closing are the same steps in opposite orders. Opening, the
73
+ * surface widens and the rows follow it down. Closing, the rows fold up
74
+ * first and the surface narrows after them, so what is seen is one thing
75
+ * putting itself away and not two things changing at once.
76
+ */
77
+ open: {
78
+ true: {
79
+ // One length, not a width and a limit on it: a change of width is
80
+ // animated from the width as written, and `100%` with a `max-width`
81
+ // would start the closing from the full width of whatever it is in.
82
+ surface: 'w-[min(100%,28rem)] rounded-3xl shadow-lg',
83
+ trigger: 'pointer-events-none opacity-0',
84
+ form: 'opacity-100 delay-150',
85
+ },
86
+ false: {
87
+ surface: 'w-[var(--pu-closed)] rounded-[1.375rem] delay-200',
88
+ trigger: 'opacity-100 delay-300',
89
+ form: 'pointer-events-none opacity-0',
90
+ },
91
+ },
92
+ filled: {
93
+ true: { results: 'grid-rows-[1fr] delay-200' },
94
+ false: { results: 'grid-rows-[0fr]' },
95
+ },
96
+ interactive: {
97
+ true: { row: 'cursor-pointer hover:bg-accent focus-visible:bg-accent focus-visible:outline-none' },
98
+ },
99
+ },
100
+ });
101
+ const CommandDockContext = createContext(null);
102
+ function useDock(part) {
103
+ const dock = useContext(CommandDockContext);
104
+ if (!dock)
105
+ throw new Error(`${part} must be used inside a <CommandDock>.`);
106
+ return dock;
107
+ }
108
+ function CommandDockRoot({ open: controlledOpen, defaultOpen = false, onOpenChange, value: controlledValue, onValueChange, onSubmit, busy = false, closedWidth = '9rem', className, style, children, ...props }) {
109
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
110
+ const [internalValue, setInternalValue] = useState('');
111
+ const open = controlledOpen ?? internalOpen;
112
+ const value = controlledValue ?? internalValue;
113
+ const root = useRef(null);
114
+ const panelId = useId();
115
+ const slots = commandDockVariants({ open });
116
+ const setOpen = useCallback((next) => {
117
+ if (controlledOpen === undefined)
118
+ setInternalOpen(next);
119
+ onOpenChange?.(next);
120
+ }, [controlledOpen, onOpenChange]);
121
+ const setValue = useCallback((next) => {
122
+ if (controlledValue === undefined)
123
+ setInternalValue(next);
124
+ onValueChange?.(next);
125
+ }, [controlledValue, onValueChange]);
126
+ const submit = useCallback(() => {
127
+ const text = value.trim();
128
+ if (text && !busy)
129
+ onSubmit?.(text);
130
+ }, [value, busy, onSubmit]);
131
+ // A press outside closes it. Escape does too, and puts focus back on the
132
+ // surface so the next key goes somewhere sensible.
133
+ useEffect(() => {
134
+ if (!open)
135
+ return;
136
+ const onPointerDown = (event) => {
137
+ if (!root.current?.contains(event.target))
138
+ setOpen(false);
139
+ };
140
+ const onKeyDown = (event) => {
141
+ if (event.key !== 'Escape' || !root.current?.contains(document.activeElement))
142
+ return;
143
+ setOpen(false);
144
+ requestAnimationFrame(() => root.current?.querySelector('[data-dock-trigger]')?.focus());
145
+ };
146
+ document.addEventListener('pointerdown', onPointerDown);
147
+ document.addEventListener('keydown', onKeyDown);
148
+ return () => {
149
+ document.removeEventListener('pointerdown', onPointerDown);
150
+ document.removeEventListener('keydown', onKeyDown);
151
+ };
152
+ }, [open, setOpen]);
153
+ const context = useMemo(() => ({ open, setOpen, value, setValue, submit, busy, panelId }), [open, setOpen, value, setValue, submit, busy, panelId]);
154
+ return (_jsx(CommandDockContext.Provider, { value: context, children: _jsx("div", { ref: root, className: slots.root({ className }), ...props, children: _jsx("div", { "data-open": open ? '' : undefined, className: slots.surface(), style: { ['--pu-closed']: closedWidth, ...style }, children: children }) }) }));
155
+ }
156
+ CommandDockRoot.displayName = 'CommandDock';
157
+ /** Keeps an element out of the tab order and the accessibility tree while it cannot be seen. */
158
+ function useInert(inert) {
159
+ const ref = useRef(null);
160
+ // Before anything else runs: the field asks for focus as the dock opens,
161
+ // and an element inside something still inert cannot take it.
162
+ useLayoutEffect(() => {
163
+ // Set on the element, not passed as a prop: how a boolean `inert` prop is
164
+ // written to the page differs between React versions.
165
+ ref.current?.toggleAttribute('inert', inert);
166
+ }, [inert]);
167
+ return ref;
168
+ }
169
+ /** The closed form: a button that opens the dock. */
170
+ function CommandDockTrigger({ className, onClick, ...props }) {
171
+ const dock = useDock('CommandDock.Trigger');
172
+ const ref = useInert(dock.open);
173
+ return (_jsx("button", { ref: ref, type: "button", "data-dock-trigger": "", "aria-expanded": dock.open, "aria-controls": dock.panelId, className: commandDockVariants({ open: dock.open }).trigger({ className }), onClick: (event) => {
174
+ onClick?.(event);
175
+ if (!event.defaultPrevented)
176
+ dock.setOpen(true);
177
+ }, ...props }));
178
+ }
179
+ CommandDockTrigger.displayName = 'CommandDock.Trigger';
180
+ /** The open form: the field, and the rows under it. */
181
+ function CommandDockPanel({ className, ...props }) {
182
+ const dock = useDock('CommandDock.Panel');
183
+ // Kept in the page while closed, so it can be seen folding away. Inert, so
184
+ // nothing in it can be reached while it cannot be seen.
185
+ const ref = useInert(!dock.open);
186
+ return (_jsx("div", { ref: ref, id: dock.panelId, "data-open": dock.open ? '' : undefined, className: commandDockVariants().panel({ className }), ...props }));
187
+ }
188
+ CommandDockPanel.displayName = 'CommandDock.Panel';
189
+ /** The field, with a button to send what is in it. Enter sends too. */
190
+ function CommandDockInput({ className, submitLabel = 'Send', 'aria-label': ariaLabel = 'Ask', ...props }) {
191
+ const dock = useDock('CommandDock.Input');
192
+ const slots = commandDockVariants({ open: dock.open });
193
+ const field = useRef(null);
194
+ // It is opened in order to type in it. Asked for without scrolling: the
195
+ // surface is still on its way to its size, and the page should not chase it.
196
+ useEffect(() => {
197
+ if (dock.open)
198
+ field.current?.focus({ preventScroll: true });
199
+ }, [dock.open]);
200
+ const send = (event) => {
201
+ event.preventDefault();
202
+ dock.submit();
203
+ };
204
+ return (_jsxs("form", { role: "search", className: slots.form(), onSubmit: send, children: [_jsx("input", { ref: field, type: "text", "aria-label": ariaLabel, value: dock.value, onChange: (event) => dock.setValue(event.target.value), className: slots.input({ className }), ...props }), _jsx("button", { type: "submit", "aria-label": submitLabel, disabled: dock.busy || dock.value.trim() === '', className: slots.submit(), children: _jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.25", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M12 19V5M5 12l7-7 7 7" }) }) })] }));
205
+ }
206
+ CommandDockInput.displayName = 'CommandDock.Input';
207
+ /** Where the rows go. It takes no room until there is something in it. */
208
+ function CommandDockResults({ className, label = 'Results', children, ...props }) {
209
+ const dock = useDock('CommandDock.Results');
210
+ const rows = Array.isArray(children) ? children.flat().filter(Boolean).length : children ? 1 : 0;
211
+ // Closed, it folds away whatever it holds.
212
+ const filled = dock.open && (rows > 0 || dock.busy);
213
+ const slots = commandDockVariants({ filled });
214
+ return (_jsx("div", { className: slots.results(), children: _jsx("div", { className: slots.resultsClip(), children: _jsx("div", { role: "list", "aria-label": label, "aria-live": "polite", className: slots.resultsInner({ className }), ...props, children: children }) }) }));
215
+ }
216
+ CommandDockResults.displayName = 'CommandDock.Results';
217
+ /** One row of the answer. It slides in when it is added. */
218
+ function CommandDockRow({ className, onSelect, children, ...props }) {
219
+ const slots = commandDockVariants({ interactive: onSelect !== undefined });
220
+ return (_jsx("div", { role: "listitem", ...props, children: onSelect ? (_jsx("button", { type: "button", onClick: onSelect, className: slots.row({ className: ['w-full text-start', className] }), children: children })) : (_jsx("div", { className: slots.row({ className }), children: children })) }));
221
+ }
222
+ CommandDockRow.displayName = 'CommandDock.Row';
223
+ /** Shown under the rows while `busy`: three dots, and words if you give it some. */
224
+ function CommandDockStatus({ className, children = 'Working', ...props }) {
225
+ const slots = commandDockVariants();
226
+ return (_jsxs("div", { role: "status", className: slots.status({ className }), ...props, children: [_jsxs("span", { "aria-hidden": "true", className: slots.dots(), children: [_jsx("span", { className: slots.dot() }), _jsx("span", { className: slots.dot({ className: '[animation-delay:150ms]' }) }), _jsx("span", { className: slots.dot({ className: '[animation-delay:300ms]' }) })] }), children] }));
227
+ }
228
+ CommandDockStatus.displayName = 'CommandDock.Status';
229
+ export const CommandDock = Object.assign(CommandDockRoot, {
230
+ Trigger: CommandDockTrigger,
231
+ Panel: CommandDockPanel,
232
+ Input: CommandDockInput,
233
+ Results: CommandDockResults,
234
+ Row: CommandDockRow,
235
+ Status: CommandDockStatus,
236
+ });
237
+ /* By name as well, for a Server Component, which cannot dot into a client
238
+ module. */
239
+ export { CommandDockTrigger, CommandDockPanel, CommandDockInput, CommandDockResults, CommandDockRow, CommandDockStatus, };