@cosxai/ui 0.19.0 → 0.21.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/package.json +2 -2
- package/src/command/CommandPalette.tsx +52 -18
- package/src/command/types.ts +5 -0
- package/src/dialogs/Modal.tsx +35 -6
- package/src/styles/base.css +10 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosxai/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "COSX design system
|
|
3
|
+
"version": "0.21.0",
|
|
4
|
+
"description": "COSX design system \u2014 React 19 component primitives shared across product-meta and other consumers",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.ts",
|
|
@@ -87,10 +87,17 @@ export function CommandPalette({
|
|
|
87
87
|
// Flat array for keyboard navigation (sequential across groups).
|
|
88
88
|
const flat = useMemo(() => grouped.flatMap((g) => g.items), [grouped]);
|
|
89
89
|
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
// Effective selection: `selectedIdx` is the raw state the keyboard/mouse
|
|
91
|
+
// set, but disabled rows and a shrunk list can leave it pointing at a
|
|
92
|
+
// non-interactive or out-of-range item. Derive the index actually shown
|
|
93
|
+
// as selected — the first enabled row when the raw one isn't usable — so
|
|
94
|
+
// a disabled item (e.g. a "coming soon" teaser) is never highlighted and
|
|
95
|
+
// the default lands on the first real result.
|
|
96
|
+
const selIdx = useMemo(() => {
|
|
97
|
+
if (flat.length === 0) return 0;
|
|
98
|
+
if (selectedIdx < flat.length && !flat[selectedIdx]?.disabled) return selectedIdx;
|
|
99
|
+
return firstEnabled(flat);
|
|
100
|
+
}, [flat, selectedIdx]);
|
|
94
101
|
|
|
95
102
|
const close = () => setOpen(false);
|
|
96
103
|
|
|
@@ -104,34 +111,34 @@ export function CommandPalette({
|
|
|
104
111
|
}
|
|
105
112
|
if (e.key === "ArrowDown") {
|
|
106
113
|
e.preventDefault();
|
|
107
|
-
setSelectedIdx((
|
|
114
|
+
setSelectedIdx(stepEnabled(flat, selIdx, 1));
|
|
108
115
|
return;
|
|
109
116
|
}
|
|
110
117
|
if (e.key === "ArrowUp") {
|
|
111
118
|
e.preventDefault();
|
|
112
|
-
setSelectedIdx((
|
|
119
|
+
setSelectedIdx(stepEnabled(flat, selIdx, -1));
|
|
113
120
|
return;
|
|
114
121
|
}
|
|
115
122
|
if (e.key === "Enter") {
|
|
116
123
|
e.preventDefault();
|
|
117
|
-
const pick = flat[
|
|
118
|
-
if (pick) pick.run({ close });
|
|
124
|
+
const pick = flat[selIdx];
|
|
125
|
+
if (pick && !pick.disabled) pick.run({ close });
|
|
119
126
|
return;
|
|
120
127
|
}
|
|
121
128
|
};
|
|
122
129
|
window.addEventListener("keydown", onKey);
|
|
123
130
|
return () => window.removeEventListener("keydown", onKey);
|
|
124
|
-
// close + flat +
|
|
125
|
-
//
|
|
131
|
+
// close + flat + selIdx are stable enough; intentionally omit close
|
|
132
|
+
// from deps so we don't re-bind every render.
|
|
126
133
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
127
|
-
}, [open, flat,
|
|
134
|
+
}, [open, flat, selIdx]);
|
|
128
135
|
|
|
129
136
|
// Scroll selected row into view.
|
|
130
137
|
useEffect(() => {
|
|
131
138
|
if (!open) return;
|
|
132
|
-
const node = listRef.current?.querySelector(`[data-row-idx="${
|
|
139
|
+
const node = listRef.current?.querySelector(`[data-row-idx="${selIdx}"]`);
|
|
133
140
|
(node as HTMLElement | null)?.scrollIntoView({ block: "nearest" });
|
|
134
|
-
}, [open,
|
|
141
|
+
}, [open, selIdx]);
|
|
135
142
|
|
|
136
143
|
if (!open) return null;
|
|
137
144
|
|
|
@@ -218,13 +225,15 @@ export function CommandPalette({
|
|
|
218
225
|
</div>
|
|
219
226
|
{g.items.map((it) => {
|
|
220
227
|
const idx = flat.indexOf(it);
|
|
221
|
-
const
|
|
228
|
+
const disabled = !!it.disabled;
|
|
229
|
+
const selected = idx === selIdx && !disabled;
|
|
222
230
|
return (
|
|
223
231
|
<div
|
|
224
232
|
key={it.key}
|
|
225
233
|
data-row-idx={idx}
|
|
226
|
-
|
|
227
|
-
|
|
234
|
+
aria-disabled={disabled || undefined}
|
|
235
|
+
onMouseEnter={disabled ? undefined : () => setSelectedIdx(idx)}
|
|
236
|
+
onClick={disabled ? undefined : () => it.run({ close })}
|
|
228
237
|
style={{
|
|
229
238
|
display: "flex",
|
|
230
239
|
alignItems: "center",
|
|
@@ -232,8 +241,13 @@ export function CommandPalette({
|
|
|
232
241
|
padding: "8px 12px",
|
|
233
242
|
borderRadius: "var(--ck-radius-sm)",
|
|
234
243
|
background: selected ? "var(--ck-accent-muted)" : "transparent",
|
|
235
|
-
color:
|
|
236
|
-
|
|
244
|
+
color: disabled
|
|
245
|
+
? "var(--ck-text-tertiary)"
|
|
246
|
+
: selected
|
|
247
|
+
? "var(--ck-accent)"
|
|
248
|
+
: "var(--ck-text-primary)",
|
|
249
|
+
opacity: disabled ? 0.6 : 1,
|
|
250
|
+
cursor: disabled ? "default" : "pointer",
|
|
237
251
|
font: "400 13px/1.3 var(--ck-font-sans)",
|
|
238
252
|
}}
|
|
239
253
|
>
|
|
@@ -293,3 +307,23 @@ export function CommandPalette({
|
|
|
293
307
|
document.body,
|
|
294
308
|
);
|
|
295
309
|
}
|
|
310
|
+
|
|
311
|
+
// First index whose item isn't disabled, or 0 if every item is (or the
|
|
312
|
+
// list is empty) — the palette has to point somewhere.
|
|
313
|
+
function firstEnabled(list: CommandItem[]): number {
|
|
314
|
+
const i = list.findIndex((it) => !it.disabled);
|
|
315
|
+
return i === -1 ? 0 : i;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Step from `from` in `dir` (+1 down / -1 up), wrapping, skipping disabled
|
|
319
|
+
// rows. Returns `from` when there's no other enabled row to land on.
|
|
320
|
+
function stepEnabled(list: CommandItem[], from: number, dir: 1 | -1): number {
|
|
321
|
+
const n = list.length;
|
|
322
|
+
if (n === 0) return 0;
|
|
323
|
+
let i = from;
|
|
324
|
+
for (let step = 0; step < n; step++) {
|
|
325
|
+
i = (i + dir + n) % n;
|
|
326
|
+
if (!list[i]?.disabled) return i;
|
|
327
|
+
}
|
|
328
|
+
return from;
|
|
329
|
+
}
|
package/src/command/types.ts
CHANGED
|
@@ -29,4 +29,9 @@ export interface CommandItem {
|
|
|
29
29
|
// matches. Use for hidden debug/test menus; pair with a "Debug"
|
|
30
30
|
// group so the reveal is self-describing.
|
|
31
31
|
secret?: string;
|
|
32
|
+
// Non-interactive placeholder: the row still renders (dimmed) but is
|
|
33
|
+
// skipped by keyboard nav, never auto-selected, and `run` never fires
|
|
34
|
+
// on it. Use for "coming soon" teasers. Distinct from `secret`, which
|
|
35
|
+
// HIDES the row; `disabled` SHOWS it as unavailable.
|
|
36
|
+
disabled?: boolean | undefined;
|
|
32
37
|
}
|
package/src/dialogs/Modal.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useEffect, type ReactNode, type CSSProperties } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
|
|
4
|
+
import { useViewport } from "../hooks/useViewport";
|
|
5
|
+
|
|
4
6
|
// Modal base. Portals to document.body so it escapes ancestor
|
|
5
7
|
// stacking contexts. Closes on Esc + backdrop click (both opt-out).
|
|
6
8
|
// Caller owns open state and slots in the header/body/footer.
|
|
@@ -25,6 +27,16 @@ export interface ModalProps {
|
|
|
25
27
|
// Optional aria-labelled-by for screen readers (point at the
|
|
26
28
|
// ModalHeader title's id).
|
|
27
29
|
labelledBy?: string;
|
|
30
|
+
// How the modal presents on PHONE viewports (no effect elsewhere):
|
|
31
|
+
// "center" (default) — the desktop card, scaled to fit
|
|
32
|
+
// "page" — full-screen, slides in from the right like
|
|
33
|
+
// a pushed native page. For content dialogs
|
|
34
|
+
// the user enters to work in.
|
|
35
|
+
// "sheet" — bottom sheet rising from the lower edge.
|
|
36
|
+
// For light pickers / short forms.
|
|
37
|
+
// Confirm-style dialogs should stay "center" — native alerts are
|
|
38
|
+
// centered cards too.
|
|
39
|
+
phonePresentation?: "center" | "page" | "sheet";
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
const WIDTHS = { sm: 340, md: 440, lg: 560, xl: 720, "2xl": 880, "3xl": 1024, "4xl": 1200, "5xl": 1440 };
|
|
@@ -37,7 +49,10 @@ export function Modal({
|
|
|
37
49
|
cardStyle,
|
|
38
50
|
children,
|
|
39
51
|
labelledBy,
|
|
52
|
+
phonePresentation = "center",
|
|
40
53
|
}: ModalProps) {
|
|
54
|
+
const vp = useViewport();
|
|
55
|
+
const mode = vp.isPhone ? phonePresentation : "center";
|
|
41
56
|
useEffect(() => {
|
|
42
57
|
if (!open || !dismissable) return;
|
|
43
58
|
const onKey = (e: KeyboardEvent) => {
|
|
@@ -79,9 +94,9 @@ export function Modal({
|
|
|
79
94
|
backdropFilter: "blur(8px)",
|
|
80
95
|
WebkitBackdropFilter: "blur(8px)",
|
|
81
96
|
display: "flex",
|
|
82
|
-
alignItems: "center",
|
|
97
|
+
alignItems: mode === "sheet" ? "flex-end" : "center",
|
|
83
98
|
justifyContent: "center",
|
|
84
|
-
padding: 16,
|
|
99
|
+
padding: mode === "center" ? 16 : 0,
|
|
85
100
|
zIndex: 100,
|
|
86
101
|
}}
|
|
87
102
|
onMouseDown={(e) => {
|
|
@@ -93,15 +108,23 @@ export function Modal({
|
|
|
93
108
|
<div
|
|
94
109
|
style={{
|
|
95
110
|
width: "100%",
|
|
96
|
-
maxWidth: width,
|
|
97
|
-
maxHeight:
|
|
111
|
+
maxWidth: mode === "center" ? width : "100%",
|
|
112
|
+
maxHeight:
|
|
113
|
+
mode === "page" ? "100dvh" : mode === "sheet" ? "92dvh" : "calc(100vh - 32px)",
|
|
114
|
+
...(mode === "page" ? { height: "100dvh" } : {}),
|
|
98
115
|
background: "var(--ck-bg-surface)",
|
|
99
116
|
border: "1px solid var(--ck-border-subtle)",
|
|
100
117
|
// Modal cards lift on the `-lg` radius rather than the
|
|
101
118
|
// page's default `-md` so the corners read as softer than
|
|
102
119
|
// the cards underneath them — supports the "this took
|
|
103
120
|
// over" cue alongside the deeper overlay shadow.
|
|
104
|
-
borderRadius:
|
|
121
|
+
borderRadius:
|
|
122
|
+
mode === "page"
|
|
123
|
+
? 0
|
|
124
|
+
: mode === "sheet"
|
|
125
|
+
? "var(--ck-radius-lg) var(--ck-radius-lg) 0 0"
|
|
126
|
+
: "var(--ck-radius-lg)",
|
|
127
|
+
...(mode === "page" ? { border: "none" } : {}),
|
|
105
128
|
// Overlay shadow > shadow-3. shadow-3 was tuned for cards
|
|
106
129
|
// sitting on a page; a modal needs to read as physically
|
|
107
130
|
// lifted off the page, not as another tier of card.
|
|
@@ -113,7 +136,13 @@ export function Modal({
|
|
|
113
136
|
color: "var(--ck-text-primary)",
|
|
114
137
|
...cardStyle,
|
|
115
138
|
}}
|
|
116
|
-
className=
|
|
139
|
+
className={
|
|
140
|
+
mode === "page"
|
|
141
|
+
? "ck-anim-page-push"
|
|
142
|
+
: mode === "sheet"
|
|
143
|
+
? "ck-anim-sheet-up"
|
|
144
|
+
: "ck-anim-popover"
|
|
145
|
+
}
|
|
117
146
|
>
|
|
118
147
|
{children}
|
|
119
148
|
</div>
|
package/src/styles/base.css
CHANGED
|
@@ -88,12 +88,19 @@ a:visited { color: var(--ck-accent); }
|
|
|
88
88
|
@keyframes ck-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
|
89
89
|
@keyframes ck-popover-enter { from { opacity: 0; transform: translateY(8px) scale(0.97); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
|
90
90
|
@keyframes ck-page-enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
|
91
|
+
/* Full-distance entrances for phone modal presentations: a pushed
|
|
92
|
+
page slides in from the right edge, a sheet rises from the bottom.
|
|
93
|
+
Unlike the 14px nudges above these travel the whole viewport. */
|
|
94
|
+
@keyframes ck-page-push { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
|
95
|
+
@keyframes ck-sheet-up { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
|
91
96
|
|
|
92
97
|
.ck-anim-slide-right { animation: ck-slide-in-right 180ms var(--ck-ease) both; }
|
|
93
98
|
.ck-anim-slide-left { animation: ck-slide-in-left 180ms var(--ck-ease) both; }
|
|
94
99
|
.ck-anim-fade { animation: ck-fade-in 180ms var(--ck-ease) both; }
|
|
95
100
|
.ck-anim-popover { animation: ck-popover-enter 160ms var(--ck-ease) both; transform-origin: top center; }
|
|
96
101
|
.ck-anim-page { animation: ck-page-enter var(--ck-dur-page) cubic-bezier(0.16, 1, 0.3, 1) both; }
|
|
102
|
+
.ck-anim-page-push { animation: ck-page-push 260ms cubic-bezier(0.32, 0.72, 0, 1) both; }
|
|
103
|
+
.ck-anim-sheet-up { animation: ck-sheet-up 260ms cubic-bezier(0.32, 0.72, 0, 1) both; }
|
|
97
104
|
|
|
98
105
|
/* Respect the user's OS-level motion preference. Cross-fade
|
|
99
106
|
transitions on bg/colour stay (below the meaningful-motion
|
|
@@ -103,7 +110,9 @@ a:visited { color: var(--ck-accent); }
|
|
|
103
110
|
.ck-anim-slide-left,
|
|
104
111
|
.ck-anim-fade,
|
|
105
112
|
.ck-anim-popover,
|
|
106
|
-
.ck-anim-page
|
|
113
|
+
.ck-anim-page,
|
|
114
|
+
.ck-anim-page-push,
|
|
115
|
+
.ck-anim-sheet-up {
|
|
107
116
|
animation: none;
|
|
108
117
|
}
|
|
109
118
|
}
|