@evinvest/uikit 0.25.0 → 0.27.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 (69) hide show
  1. package/README.md +142 -8
  2. package/dist/components/account-menu.js +70 -0
  3. package/dist/components/account-menu.js.map +1 -0
  4. package/dist/components/alert-dialog.js +5 -4
  5. package/dist/components/alert-dialog.js.map +1 -1
  6. package/dist/components/app-shell.js +11 -0
  7. package/dist/components/app-shell.js.map +1 -1
  8. package/dist/components/command.js +129 -20
  9. package/dist/components/command.js.map +1 -1
  10. package/dist/components/context-menu.js +8 -8
  11. package/dist/components/context-menu.js.map +1 -1
  12. package/dist/components/dialog.js +6 -4
  13. package/dist/components/dialog.js.map +1 -1
  14. package/dist/components/drawer.js +15 -9
  15. package/dist/components/drawer.js.map +1 -1
  16. package/dist/components/dropdown-menu.js +13 -10
  17. package/dist/components/dropdown-menu.js.map +1 -1
  18. package/dist/components/info-tip.js +2 -2
  19. package/dist/components/info-tip.js.map +1 -1
  20. package/dist/components/list-rows.js +73 -0
  21. package/dist/components/list-rows.js.map +1 -0
  22. package/dist/components/menubar.js +18 -5
  23. package/dist/components/menubar.js.map +1 -1
  24. package/dist/components/navigation-menu.js +2 -2
  25. package/dist/components/navigation-menu.js.map +1 -1
  26. package/dist/components/popover.js +2 -2
  27. package/dist/components/popover.js.map +1 -1
  28. package/dist/components/select.js +2 -2
  29. package/dist/components/select.js.map +1 -1
  30. package/dist/components/sheet.js +12 -5
  31. package/dist/components/sheet.js.map +1 -1
  32. package/dist/components/sonner.js +1 -0
  33. package/dist/components/sonner.js.map +1 -1
  34. package/dist/components/table.js +24 -7
  35. package/dist/components/table.js.map +1 -1
  36. package/dist/components/toggle-group.js +1 -1
  37. package/dist/components/toggle-group.js.map +1 -1
  38. package/dist/components/top-bar.js +21 -0
  39. package/dist/components/top-bar.js.map +1 -0
  40. package/dist/generated/alert-dialog.js +2 -2
  41. package/dist/generated/alert-dialog.js.map +1 -1
  42. package/dist/generated/context-menu.js +1 -1
  43. package/dist/generated/context-menu.js.map +1 -1
  44. package/dist/generated/dialog.js +3 -3
  45. package/dist/generated/dialog.js.map +1 -1
  46. package/dist/generated/drawer.js +1 -1
  47. package/dist/generated/drawer.js.map +1 -1
  48. package/dist/generated/dropdown-menu.js +1 -1
  49. package/dist/generated/dropdown-menu.js.map +1 -1
  50. package/dist/generated/menubar.js +1 -1
  51. package/dist/generated/menubar.js.map +1 -1
  52. package/dist/generated/sheet.js +3 -3
  53. package/dist/generated/sheet.js.map +1 -1
  54. package/dist/generated/table.js +20 -3
  55. package/dist/generated/table.js.map +1 -1
  56. package/dist/generated/toggle.js +2 -2
  57. package/dist/generated/toggle.js.map +1 -1
  58. package/dist/index.d.ts +199 -27
  59. package/dist/index.js +5 -2
  60. package/dist/primitives/dismissable-layer.js +80 -15
  61. package/dist/primitives/dismissable-layer.js.map +1 -1
  62. package/dist/primitives/focus-scope.js +22 -6
  63. package/dist/primitives/focus-scope.js.map +1 -1
  64. package/dist/primitives/use-roving-focus.js +13 -7
  65. package/dist/primitives/use-roving-focus.js.map +1 -1
  66. package/package.json +1 -1
  67. package/styles/ev.css +4 -2
  68. package/styles/theme.css +34 -2
  69. package/styles/tokens.css +42 -5
@@ -16,15 +16,25 @@ function focusable(root) {
16
16
  (el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement
17
17
  );
18
18
  }
19
- function useFocusScope(enabled) {
19
+ function usable(el) {
20
+ return !!el && el !== document.body && el.isConnected;
21
+ }
22
+ function focusRoot(root) {
23
+ if (!root.hasAttribute("tabindex")) root.tabIndex = -1;
24
+ root.focus();
25
+ }
26
+ function useFocusScope(enabled, { initialFocus = "first", returnFocusTo } = {}) {
20
27
  const ref = React.useRef(null);
28
+ const fallbackRef = React.useRef(returnFocusTo);
29
+ fallbackRef.current = returnFocusTo;
21
30
  React.useEffect(() => {
22
31
  if (!enabled) return;
23
32
  const root = ref.current;
24
33
  if (!root) return;
25
34
  const previouslyFocused = document.activeElement;
26
- const first = focusable(root)[0];
27
- (first ?? root).focus();
35
+ const first = initialFocus === "first" ? focusable(root)[0] : void 0;
36
+ if (first) first.focus();
37
+ else focusRoot(root);
28
38
  function onKeyDown(event) {
29
39
  if (event.key !== "Tab" || !root) return;
30
40
  const items = focusable(root);
@@ -35,7 +45,10 @@ function useFocusScope(enabled) {
35
45
  const firstEl = items[0];
36
46
  const lastEl = items[items.length - 1];
37
47
  const active = document.activeElement;
38
- if (event.shiftKey && active === firstEl) {
48
+ if (active === root) {
49
+ event.preventDefault();
50
+ (event.shiftKey ? lastEl : firstEl).focus();
51
+ } else if (event.shiftKey && active === firstEl) {
39
52
  event.preventDefault();
40
53
  lastEl.focus();
41
54
  } else if (!event.shiftKey && active === lastEl) {
@@ -46,9 +59,12 @@ function useFocusScope(enabled) {
46
59
  root.addEventListener("keydown", onKeyDown);
47
60
  return () => {
48
61
  root.removeEventListener("keydown", onKeyDown);
49
- previouslyFocused?.focus?.();
62
+ const active = document.activeElement;
63
+ if (active && active !== document.body && !root.contains(active)) return;
64
+ const target = usable(previouslyFocused) ? previouslyFocused : fallbackRef.current?.current;
65
+ if (usable(target)) target.focus({ preventScroll: true });
50
66
  };
51
- }, [enabled]);
67
+ }, [enabled, initialFocus]);
52
68
  return ref;
53
69
  }
54
70
  export {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/primitives/focus-scope.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\n\n// `tabindex=\"-1\"` opts an element out of Tab order whatever its tag, so the\n// exclusion goes on every branch — otherwise a roving-tabindex grid's parked\n// cells would count as tab stops.\nconst FOCUSABLE = [\n \"a[href]\",\n \"button:not([disabled])\",\n \"textarea:not([disabled])\",\n \"input:not([disabled])\",\n \"select:not([disabled])\",\n \"[tabindex]\",\n]\n .map(sel => `${sel}:not([tabindex=\"-1\"])`)\n .join(\",\");\n\n/**\n * Every enabled, Tab-reachable candidate under `root` in DOM (= Tab) order,\n * before the layout-based visibility filter; `tabindex=\"-1\"` is skipped on any\n * tag. For content that is fully visible while it exists (a popover), this is\n * the tab order itself. Internal to the kit.\n */\nexport function focusCandidates(root: HTMLElement): HTMLElement[] {\n return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE));\n}\n\nfunction focusable(root: HTMLElement): HTMLElement[] {\n return focusCandidates(root).filter(\n (el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement,\n );\n}\n\n/**\n * Traps Tab focus inside the returned ref's subtree and restores focus to the\n * previously-focused element on unmount — the dep-light core of dialogs/menus,\n * replacing `@radix-ui/react-focus-scope`. On mount, focuses the first\n * focusable (or the container itself).\n *\n * Rust dialogs rely on the browser's native focus order within a fixed overlay;\n * see the README \"Limitations\".\n */\nexport function useFocusScope(enabled: boolean): React.RefObject<HTMLDivElement | null> {\n const ref = React.useRef<HTMLDivElement | null>(null);\n\n React.useEffect(() => {\n if (!enabled) return;\n const root = ref.current;\n if (!root) return;\n\n const previouslyFocused = document.activeElement as HTMLElement | null;\n const first = focusable(root)[0];\n (first ?? root).focus();\n\n function onKeyDown(event: KeyboardEvent) {\n if (event.key !== \"Tab\" || !root) return;\n const items = focusable(root);\n if (items.length === 0) {\n event.preventDefault();\n return;\n }\n const firstEl = items[0]!;\n const lastEl = items[items.length - 1]!;\n const active = document.activeElement;\n if (event.shiftKey && active === firstEl) {\n event.preventDefault();\n lastEl.focus();\n } else if (!event.shiftKey && active === lastEl) {\n event.preventDefault();\n firstEl.focus();\n }\n }\n\n root.addEventListener(\"keydown\", onKeyDown);\n return () => {\n root.removeEventListener(\"keydown\", onKeyDown);\n previouslyFocused?.focus?.();\n };\n }, [enabled]);\n\n return ref;\n}\n"],"mappings":";AAEA,YAAY,WAAW;AAKvB,MAAM,YAAY;AAAA,EAChB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,EACG,IAAI,SAAO,GAAG,GAAG,uBAAuB,EACxC,KAAK,GAAG;AAQJ,SAAS,gBAAgB,MAAkC;AAChE,SAAO,MAAM,KAAK,KAAK,iBAA8B,SAAS,CAAC;AACjE;AAEA,SAAS,UAAU,MAAkC;AACnD,SAAO,gBAAgB,IAAI,EAAE;AAAA,IAC3B,CAAC,OAAO,GAAG,cAAc,KAAK,GAAG,eAAe,KAAK,OAAO,SAAS;AAAA,EACvE;AACF;AAWO,SAAS,cAAc,SAA0D;AACtF,QAAM,MAAM,MAAM,OAA8B,IAAI;AAEpD,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,QAAS;AACd,UAAM,OAAO,IAAI;AACjB,QAAI,CAAC,KAAM;AAEX,UAAM,oBAAoB,SAAS;AACnC,UAAM,QAAQ,UAAU,IAAI,EAAE,CAAC;AAC/B,KAAC,SAAS,MAAM,MAAM;AAEtB,aAAS,UAAU,OAAsB;AACvC,UAAI,MAAM,QAAQ,SAAS,CAAC,KAAM;AAClC,YAAM,QAAQ,UAAU,IAAI;AAC5B,UAAI,MAAM,WAAW,GAAG;AACtB,cAAM,eAAe;AACrB;AAAA,MACF;AACA,YAAM,UAAU,MAAM,CAAC;AACvB,YAAM,SAAS,MAAM,MAAM,SAAS,CAAC;AACrC,YAAM,SAAS,SAAS;AACxB,UAAI,MAAM,YAAY,WAAW,SAAS;AACxC,cAAM,eAAe;AACrB,eAAO,MAAM;AAAA,MACf,WAAW,CAAC,MAAM,YAAY,WAAW,QAAQ;AAC/C,cAAM,eAAe;AACrB,gBAAQ,MAAM;AAAA,MAChB;AAAA,IACF;AAEA,SAAK,iBAAiB,WAAW,SAAS;AAC1C,WAAO,MAAM;AACX,WAAK,oBAAoB,WAAW,SAAS;AAC7C,yBAAmB,QAAQ;AAAA,IAC7B;AAAA,EACF,GAAG,CAAC,OAAO,CAAC;AAEZ,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/primitives/focus-scope.tsx"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\n\n// `tabindex=\"-1\"` opts an element out of Tab order whatever its tag, so the\n// exclusion goes on every branch — otherwise a roving-tabindex grid's parked\n// cells would count as tab stops.\nconst FOCUSABLE = [\n \"a[href]\",\n \"button:not([disabled])\",\n \"textarea:not([disabled])\",\n \"input:not([disabled])\",\n \"select:not([disabled])\",\n \"[tabindex]\",\n]\n .map(sel => `${sel}:not([tabindex=\"-1\"])`)\n .join(\",\");\n\n/**\n * Every enabled, Tab-reachable candidate under `root` in DOM (= Tab) order,\n * before the layout-based visibility filter; `tabindex=\"-1\"` is skipped on any\n * tag. For content that is fully visible while it exists (a popover), this is\n * the tab order itself. Internal to the kit.\n */\nexport function focusCandidates(root: HTMLElement): HTMLElement[] {\n return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE));\n}\n\nfunction focusable(root: HTMLElement): HTMLElement[] {\n return focusCandidates(root).filter(\n (el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement,\n );\n}\n\n/**\n * Where focus lands when the scope opens. `first` is the first Tab stop inside\n * (an input, an alert dialog's least destructive action); `container` is the\n * scope root itself, so a reader starts at the top and the first Tab reaches the\n * first control — right for a panel of content, where the first stop may be an\n * action one keypress away from firing.\n */\nexport type InitialFocus = \"first\" | \"container\";\n\nexport interface FocusScopeOptions {\n /** Default `first`; falls back to the container when nothing inside is focusable. */\n initialFocus?: InitialFocus;\n /**\n * Where focus goes back on close when the element focused before opening is\n * no use: `<body>` (Safari does not focus a clicked button), or detached (the\n * opener was re-rendered away). Pass the overlay's trigger.\n */\n returnFocusTo?: React.RefObject<HTMLElement | null>;\n}\n\nfunction usable(el: HTMLElement | null | undefined): el is HTMLElement {\n return !!el && el !== document.body && el.isConnected;\n}\n\n// A `<div>` without a tabindex ignores `.focus()`, so a scope with no Tab stops\n// used to leave focus on the trigger behind the scrim. `-1` makes the root\n// focusable by script (and by a click on its empty area, which keeps focus in\n// the scope) without adding it to the Tab order.\nfunction focusRoot(root: HTMLElement) {\n if (!root.hasAttribute(\"tabindex\")) root.tabIndex = -1;\n root.focus();\n}\n\n/**\n * Traps Tab focus inside the returned ref's subtree and restores focus to the\n * previously-focused element on unmount — the dep-light core of dialogs/menus,\n * replacing `@radix-ui/react-focus-scope`. On mount, focuses per\n * `initialFocus`; the root gets `tabindex=\"-1\"` whenever it is the target.\n *\n * Rust dialogs rely on the browser's native focus order within a fixed overlay;\n * see the README \"Limitations\".\n */\nexport function useFocusScope(\n enabled: boolean,\n { initialFocus = \"first\", returnFocusTo }: FocusScopeOptions = {},\n): React.RefObject<HTMLDivElement | null> {\n const ref = React.useRef<HTMLDivElement | null>(null);\n // Read at close, not captured at open: a trigger re-rendered while the\n // overlay was up is a new node by then.\n const fallbackRef = React.useRef(returnFocusTo);\n fallbackRef.current = returnFocusTo;\n\n React.useEffect(() => {\n if (!enabled) return;\n const root = ref.current;\n if (!root) return;\n\n const previouslyFocused = document.activeElement as HTMLElement | null;\n const first = initialFocus === \"first\" ? focusable(root)[0] : undefined;\n if (first) first.focus();\n else focusRoot(root);\n\n function onKeyDown(event: KeyboardEvent) {\n if (event.key !== \"Tab\" || !root) return;\n const items = focusable(root);\n if (items.length === 0) {\n event.preventDefault();\n return;\n }\n const firstEl = items[0]!;\n const lastEl = items[items.length - 1]!;\n const active = document.activeElement;\n // From the root itself Shift+Tab would leave the scope for whatever\n // precedes the portal, so both directions are steered explicitly.\n if (active === root) {\n event.preventDefault();\n (event.shiftKey ? lastEl : firstEl).focus();\n } else if (event.shiftKey && active === firstEl) {\n event.preventDefault();\n lastEl.focus();\n } else if (!event.shiftKey && active === lastEl) {\n event.preventDefault();\n firstEl.focus();\n }\n }\n\n root.addEventListener(\"keydown\", onKeyDown);\n return () => {\n root.removeEventListener(\"keydown\", onKeyDown);\n // Focus that already left the scope (a click outside landed on a field)\n // is the user's; only focus still inside, or dropped to <body>, returns.\n const active = document.activeElement;\n if (active && active !== document.body && !root.contains(active)) return;\n const target = usable(previouslyFocused) ? previouslyFocused : fallbackRef.current?.current;\n if (usable(target)) target.focus({ preventScroll: true });\n };\n }, [enabled, initialFocus]);\n\n return ref;\n}\n"],"mappings":";AAEA,YAAY,WAAW;AAKvB,MAAM,YAAY;AAAA,EAChB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,EACG,IAAI,SAAO,GAAG,GAAG,uBAAuB,EACxC,KAAK,GAAG;AAQJ,SAAS,gBAAgB,MAAkC;AAChE,SAAO,MAAM,KAAK,KAAK,iBAA8B,SAAS,CAAC;AACjE;AAEA,SAAS,UAAU,MAAkC;AACnD,SAAO,gBAAgB,IAAI,EAAE;AAAA,IAC3B,CAAC,OAAO,GAAG,cAAc,KAAK,GAAG,eAAe,KAAK,OAAO,SAAS;AAAA,EACvE;AACF;AAsBA,SAAS,OAAO,IAAuD;AACrE,SAAO,CAAC,CAAC,MAAM,OAAO,SAAS,QAAQ,GAAG;AAC5C;AAMA,SAAS,UAAU,MAAmB;AACpC,MAAI,CAAC,KAAK,aAAa,UAAU,EAAG,MAAK,WAAW;AACpD,OAAK,MAAM;AACb;AAWO,SAAS,cACd,SACA,EAAE,eAAe,SAAS,cAAc,IAAuB,CAAC,GACxB;AACxC,QAAM,MAAM,MAAM,OAA8B,IAAI;AAGpD,QAAM,cAAc,MAAM,OAAO,aAAa;AAC9C,cAAY,UAAU;AAEtB,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,QAAS;AACd,UAAM,OAAO,IAAI;AACjB,QAAI,CAAC,KAAM;AAEX,UAAM,oBAAoB,SAAS;AACnC,UAAM,QAAQ,iBAAiB,UAAU,UAAU,IAAI,EAAE,CAAC,IAAI;AAC9D,QAAI,MAAO,OAAM,MAAM;AAAA,QAClB,WAAU,IAAI;AAEnB,aAAS,UAAU,OAAsB;AACvC,UAAI,MAAM,QAAQ,SAAS,CAAC,KAAM;AAClC,YAAM,QAAQ,UAAU,IAAI;AAC5B,UAAI,MAAM,WAAW,GAAG;AACtB,cAAM,eAAe;AACrB;AAAA,MACF;AACA,YAAM,UAAU,MAAM,CAAC;AACvB,YAAM,SAAS,MAAM,MAAM,SAAS,CAAC;AACrC,YAAM,SAAS,SAAS;AAGxB,UAAI,WAAW,MAAM;AACnB,cAAM,eAAe;AACrB,SAAC,MAAM,WAAW,SAAS,SAAS,MAAM;AAAA,MAC5C,WAAW,MAAM,YAAY,WAAW,SAAS;AAC/C,cAAM,eAAe;AACrB,eAAO,MAAM;AAAA,MACf,WAAW,CAAC,MAAM,YAAY,WAAW,QAAQ;AAC/C,cAAM,eAAe;AACrB,gBAAQ,MAAM;AAAA,MAChB;AAAA,IACF;AAEA,SAAK,iBAAiB,WAAW,SAAS;AAC1C,WAAO,MAAM;AACX,WAAK,oBAAoB,WAAW,SAAS;AAG7C,YAAM,SAAS,SAAS;AACxB,UAAI,UAAU,WAAW,SAAS,QAAQ,CAAC,KAAK,SAAS,MAAM,EAAG;AAClE,YAAM,SAAS,OAAO,iBAAiB,IAAI,oBAAoB,YAAY,SAAS;AACpF,UAAI,OAAO,MAAM,EAAG,QAAO,MAAM,EAAE,eAAe,KAAK,CAAC;AAAA,IAC1D;AAAA,EACF,GAAG,CAAC,SAAS,YAAY,CAAC;AAE1B,SAAO;AACT;","names":[]}
@@ -3,12 +3,17 @@ import * as React from "react";
3
3
  function useRovingFocus(opts) {
4
4
  const { count, orientation = "vertical", loop = true, initial = 0 } = opts;
5
5
  const [activeIndex, setActiveIndex] = React.useState(initial);
6
- const next = (dir) => setActiveIndex((i) => {
7
- let n = i + dir;
8
- if (n < 0) n = loop ? count - 1 : 0;
9
- else if (n >= count) n = loop ? 0 : count - 1;
10
- return n;
11
- });
6
+ const resolveCount = () => typeof count === "function" ? count() : count;
7
+ const next = (dir) => {
8
+ const total = resolveCount();
9
+ if (total <= 0) return;
10
+ setActiveIndex((i) => {
11
+ let n = Math.min(Math.max(i, 0), total - 1) + dir;
12
+ if (n < 0) n = loop ? total - 1 : 0;
13
+ else if (n >= total) n = loop ? 0 : total - 1;
14
+ return n;
15
+ });
16
+ };
12
17
  const onKeyDown = (event) => {
13
18
  const horiz = orientation === "horizontal" || orientation === "both";
14
19
  const vert = orientation === "vertical" || orientation === "both";
@@ -29,7 +34,8 @@ function useRovingFocus(opts) {
29
34
  setActiveIndex(0);
30
35
  } else if (event.key === "End") {
31
36
  event.preventDefault();
32
- setActiveIndex(count - 1);
37
+ const total = resolveCount();
38
+ if (total > 0) setActiveIndex(total - 1);
33
39
  }
34
40
  };
35
41
  return { activeIndex, setActiveIndex, onKeyDown };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/primitives/use-roving-focus.ts"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * Arrow-key roving focus over a set of items — the dep-light core of menus,\n * tabs, radio-groups and toolbars (replacing `@radix-ui/react-roving-focus`).\n * Returns an `onKeyDown` to spread on the container and the active index; items\n * should set `tabIndex={index === activeIndex ? 0 : -1}`.\n *\n * Mirrors the keyboard navigation Rust drives with a focused-index signal.\n */\nexport function useRovingFocus(opts: {\n count: number;\n orientation?: \"horizontal\" | \"vertical\" | \"both\";\n loop?: boolean;\n initial?: number;\n}): {\n activeIndex: number;\n setActiveIndex: (i: number) => void;\n onKeyDown: (event: React.KeyboardEvent) => void;\n} {\n const { count, orientation = \"vertical\", loop = true, initial = 0 } = opts;\n const [activeIndex, setActiveIndex] = React.useState(initial);\n\n const next = (dir: 1 | -1) =>\n setActiveIndex((i) => {\n let n = i + dir;\n if (n < 0) n = loop ? count - 1 : 0;\n else if (n >= count) n = loop ? 0 : count - 1;\n return n;\n });\n\n const onKeyDown = (event: React.KeyboardEvent) => {\n const horiz = orientation === \"horizontal\" || orientation === \"both\";\n const vert = orientation === \"vertical\" || orientation === \"both\";\n if (vert && event.key === \"ArrowDown\") {\n event.preventDefault();\n next(1);\n } else if (vert && event.key === \"ArrowUp\") {\n event.preventDefault();\n next(-1);\n } else if (horiz && event.key === \"ArrowRight\") {\n event.preventDefault();\n next(1);\n } else if (horiz && event.key === \"ArrowLeft\") {\n event.preventDefault();\n next(-1);\n } else if (event.key === \"Home\") {\n event.preventDefault();\n setActiveIndex(0);\n } else if (event.key === \"End\") {\n event.preventDefault();\n setActiveIndex(count - 1);\n }\n };\n\n return { activeIndex, setActiveIndex, onKeyDown };\n}\n"],"mappings":";AAEA,YAAY,WAAW;AAUhB,SAAS,eAAe,MAS7B;AACA,QAAM,EAAE,OAAO,cAAc,YAAY,OAAO,MAAM,UAAU,EAAE,IAAI;AACtE,QAAM,CAAC,aAAa,cAAc,IAAI,MAAM,SAAS,OAAO;AAE5D,QAAM,OAAO,CAAC,QACZ,eAAe,CAAC,MAAM;AACpB,QAAI,IAAI,IAAI;AACZ,QAAI,IAAI,EAAG,KAAI,OAAO,QAAQ,IAAI;AAAA,aACzB,KAAK,MAAO,KAAI,OAAO,IAAI,QAAQ;AAC5C,WAAO;AAAA,EACT,CAAC;AAEH,QAAM,YAAY,CAAC,UAA+B;AAChD,UAAM,QAAQ,gBAAgB,gBAAgB,gBAAgB;AAC9D,UAAM,OAAO,gBAAgB,cAAc,gBAAgB;AAC3D,QAAI,QAAQ,MAAM,QAAQ,aAAa;AACrC,YAAM,eAAe;AACrB,WAAK,CAAC;AAAA,IACR,WAAW,QAAQ,MAAM,QAAQ,WAAW;AAC1C,YAAM,eAAe;AACrB,WAAK,EAAE;AAAA,IACT,WAAW,SAAS,MAAM,QAAQ,cAAc;AAC9C,YAAM,eAAe;AACrB,WAAK,CAAC;AAAA,IACR,WAAW,SAAS,MAAM,QAAQ,aAAa;AAC7C,YAAM,eAAe;AACrB,WAAK,EAAE;AAAA,IACT,WAAW,MAAM,QAAQ,QAAQ;AAC/B,YAAM,eAAe;AACrB,qBAAe,CAAC;AAAA,IAClB,WAAW,MAAM,QAAQ,OAAO;AAC9B,YAAM,eAAe;AACrB,qBAAe,QAAQ,CAAC;AAAA,IAC1B;AAAA,EACF;AAEA,SAAO,EAAE,aAAa,gBAAgB,UAAU;AAClD;","names":[]}
1
+ {"version":3,"sources":["../../src/primitives/use-roving-focus.ts"],"sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * Arrow-key roving focus over a set of items — the dep-light core of menus,\n * tabs, radio-groups and toolbars (replacing `@radix-ui/react-roving-focus`).\n * Returns an `onKeyDown` to spread on the container and the active index; items\n * should set `tabIndex={index === activeIndex ? 0 : -1}`.\n *\n * `count` may be a getter: containers whose items are only known from the DOM\n * (menus with conditionally rendered or disabled items, inline sub-menus) pass\n * one so the bound is read at keydown time — never during render, which keeps\n * the hook safe under SSR.\n *\n * Mirrors the keyboard navigation Rust drives with a focused-index signal.\n */\nexport function useRovingFocus(opts: {\n count: number | (() => number);\n orientation?: \"horizontal\" | \"vertical\" | \"both\";\n loop?: boolean;\n initial?: number;\n}): {\n activeIndex: number;\n setActiveIndex: (i: number) => void;\n onKeyDown: (event: React.KeyboardEvent) => void;\n} {\n const { count, orientation = \"vertical\", loop = true, initial = 0 } = opts;\n const [activeIndex, setActiveIndex] = React.useState(initial);\n\n const resolveCount = () => (typeof count === \"function\" ? count() : count);\n\n const next = (dir: 1 | -1) => {\n const total = resolveCount();\n if (total <= 0) return;\n setActiveIndex((i) => {\n // Clamp first: the item set may have shrunk since the index was set.\n let n = Math.min(Math.max(i, 0), total - 1) + dir;\n if (n < 0) n = loop ? total - 1 : 0;\n else if (n >= total) n = loop ? 0 : total - 1;\n return n;\n });\n };\n\n const onKeyDown = (event: React.KeyboardEvent) => {\n const horiz = orientation === \"horizontal\" || orientation === \"both\";\n const vert = orientation === \"vertical\" || orientation === \"both\";\n if (vert && event.key === \"ArrowDown\") {\n event.preventDefault();\n next(1);\n } else if (vert && event.key === \"ArrowUp\") {\n event.preventDefault();\n next(-1);\n } else if (horiz && event.key === \"ArrowRight\") {\n event.preventDefault();\n next(1);\n } else if (horiz && event.key === \"ArrowLeft\") {\n event.preventDefault();\n next(-1);\n } else if (event.key === \"Home\") {\n event.preventDefault();\n setActiveIndex(0);\n } else if (event.key === \"End\") {\n event.preventDefault();\n const total = resolveCount();\n if (total > 0) setActiveIndex(total - 1);\n }\n };\n\n return { activeIndex, setActiveIndex, onKeyDown };\n}\n"],"mappings":";AAEA,YAAY,WAAW;AAehB,SAAS,eAAe,MAS7B;AACA,QAAM,EAAE,OAAO,cAAc,YAAY,OAAO,MAAM,UAAU,EAAE,IAAI;AACtE,QAAM,CAAC,aAAa,cAAc,IAAI,MAAM,SAAS,OAAO;AAE5D,QAAM,eAAe,MAAO,OAAO,UAAU,aAAa,MAAM,IAAI;AAEpE,QAAM,OAAO,CAAC,QAAgB;AAC5B,UAAM,QAAQ,aAAa;AAC3B,QAAI,SAAS,EAAG;AAChB,mBAAe,CAAC,MAAM;AAEpB,UAAI,IAAI,KAAK,IAAI,KAAK,IAAI,GAAG,CAAC,GAAG,QAAQ,CAAC,IAAI;AAC9C,UAAI,IAAI,EAAG,KAAI,OAAO,QAAQ,IAAI;AAAA,eACzB,KAAK,MAAO,KAAI,OAAO,IAAI,QAAQ;AAC5C,aAAO;AAAA,IACT,CAAC;AAAA,EACH;AAEA,QAAM,YAAY,CAAC,UAA+B;AAChD,UAAM,QAAQ,gBAAgB,gBAAgB,gBAAgB;AAC9D,UAAM,OAAO,gBAAgB,cAAc,gBAAgB;AAC3D,QAAI,QAAQ,MAAM,QAAQ,aAAa;AACrC,YAAM,eAAe;AACrB,WAAK,CAAC;AAAA,IACR,WAAW,QAAQ,MAAM,QAAQ,WAAW;AAC1C,YAAM,eAAe;AACrB,WAAK,EAAE;AAAA,IACT,WAAW,SAAS,MAAM,QAAQ,cAAc;AAC9C,YAAM,eAAe;AACrB,WAAK,CAAC;AAAA,IACR,WAAW,SAAS,MAAM,QAAQ,aAAa;AAC7C,YAAM,eAAe;AACrB,WAAK,EAAE;AAAA,IACT,WAAW,MAAM,QAAQ,QAAQ;AAC/B,YAAM,eAAe;AACrB,qBAAe,CAAC;AAAA,IAClB,WAAW,MAAM,QAAQ,OAAO;AAC9B,YAAM,eAAe;AACrB,YAAM,QAAQ,aAAa;AAC3B,UAAI,QAAQ,EAAG,gBAAe,QAAQ,CAAC;AAAA,IACzC;AAAA,EACF;AAEA,SAAO,EAAE,aAAa,gBAAgB,UAAU;AAClD;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/uikit",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Dep-light React UI kit: shadcn-semantics components with zero @radix-ui / cva, mirroring the ev_lib::uikit Rust feature. Ships the shared design tokens.",
5
5
  "license": "BlueOak-1.0.0",
6
6
  "publishConfig": {
package/styles/ev.css CHANGED
@@ -60,8 +60,10 @@
60
60
  --accent-warn: #f2c94c;
61
61
  --on-accent-warn: #070d18;
62
62
  /* lifted off the brand red #da251d, which failed AA on a card. This one value
63
- * carries both the fill and the text; the vivid brand red is a gitops
64
- * constant, not a UI token. */
63
+ * is the fill and the text on a bare surface; on its own 10 % tint (a
64
+ * destructive menu row) the text is the derived --accent-error-ink, which
65
+ * EV leaves to the contract: #ec8379, 4.83:1 on that tint over popover. The
66
+ * vivid brand red is a gitops constant, not a UI token. */
65
67
  --accent-error: #ef5b52;
66
68
  --on-accent-error: #070d18;
67
69
 
package/styles/theme.css CHANGED
@@ -1,7 +1,8 @@
1
1
  /* AUTO-GENERATED by `nix run .#gen` from the repo-root theme.css — do not edit. */
2
2
  /*
3
- * Toast (sonner) and Drawer enter/exit motion, the entrances (rise / stagger / settled /
4
- * skeleton) and the app-shell markers, shared by both ports. Imported by the token
3
+ * Toast (sonner) and Drawer enter/exit motion, the reduced-motion fade for Sheet and
4
+ * the dialogs, the entrances (rise / stagger / settled / skeleton) and the app-shell
5
+ * markers, shared by both ports. Imported by the token
5
6
  * sheets so the lifecycle ships with the theme contract.
6
7
  */
7
8
 
@@ -307,6 +308,30 @@
307
308
  }
308
309
  }
309
310
 
311
+ /* ── Sheet and dialogs under reduced motion ──────────────────────────────────
312
+ Both ports animate these with tw-animate-css (`animate-in` / `animate-out` +
313
+ `slide-*` / `zoom-*`), whose `enter` / `exit` keyframes read the
314
+ --tw-enter-* / --tw-exit-* variables the slide and zoom utilities set. Zeroing
315
+ the travel and the scale here leaves a fade: the dialogs already fade, the
316
+ sheet does not, so its opacity is set too. The keyframes still run on their
317
+ usual clock, so the TS port's exit `animationend` unmount is untouched.
318
+ Unlayered, like the rest of this sheet, so it outranks those utilities
319
+ without !important. */
320
+ @media (prefers-reduced-motion: reduce) {
321
+ [data-slot="sheet-content"],
322
+ [data-slot="dialog-content"],
323
+ [data-slot="alert-dialog-content"] {
324
+ --tw-enter-translate-x: 0;
325
+ --tw-enter-translate-y: 0;
326
+ --tw-exit-translate-x: 0;
327
+ --tw-exit-translate-y: 0;
328
+ --tw-enter-scale: 1;
329
+ --tw-exit-scale: 1;
330
+ --tw-enter-opacity: 0;
331
+ --tw-exit-opacity: 0;
332
+ }
333
+ }
334
+
310
335
  /* ── Entrances: rise, stagger, settled, skeleton ─────────────────────────────
311
336
  One keyframe for every "arriving" thing, driven by attributes so a consumer
312
337
  without a motion library (and a Server Component) gets it for free:
@@ -513,6 +538,7 @@
513
538
  --color-on-accent-warn: var(--on-accent-warn);
514
539
  --color-accent-error: var(--accent-error);
515
540
  --color-on-accent-error: var(--on-accent-error);
541
+ --color-accent-error-ink: var(--accent-error-ink);
516
542
 
517
543
  /* charts — distinguishable hues carrying no significance */
518
544
  --color-chart-1: var(--chart-1);
@@ -540,6 +566,12 @@
540
566
  * side — ink over white needs ~47% to clear it, dark lands near 4:1. */
541
567
  --input: color-mix(in srgb, var(--ink) 48%, transparent);
542
568
  --ring: var(--primary-ink);
569
+ /* the error role as text on its own 10 % tint — a destructive menu row
570
+ * (lib#166). The fill alone read 3.76:1 there on EV and 2.48:1 on a dark
571
+ * brand; 30 % of the ink pulls it toward whichever polarity the text needs.
572
+ * Derived rather than required so a brand file that predates it still
573
+ * renders; one that misses the floor pins its own (docs/spec/accents.md). */
574
+ --accent-error-ink: color-mix(in srgb, var(--accent-error) 70%, var(--ink));
543
575
  }
544
576
 
545
577
  /* Geometry. A `[data-brand]` scope is a theme root of its own, so it restarts
package/styles/tokens.css CHANGED
@@ -21,6 +21,8 @@
21
21
  * accent-trace · accent-debug · accent-info · accent-warn · accent-error
22
22
  * primary-ink — the primary role as ink on a surface, where the
23
23
  * fill and the readable value are different colours
24
+ * accent-error-ink — the error role as text on its own tint;
25
+ * derived from accent-error and ink, a palette may pin it
24
26
  * SCALARS radius · control-* · display-scale · page-* · shell-* · shadows · fonts
25
27
  * MOTION ev-ease-* · ev-dur-* · ev-rise · ev-stagger(-section)
26
28
  * CHARTS chart-1 … chart-5
@@ -37,12 +39,14 @@
37
39
  *
38
40
  * Accents are ordered by significance, not by meaning; see docs/spec/accents.md.
39
41
  * Their values are read outside CSS (gitops mirrors them into Discord embed
40
- * bars), so they stay plain hex — no color-mix.
42
+ * bars), so they stay plain hex — no color-mix. `accent-error-ink` is not an
43
+ * accent but a reading of one, so it lives with the derived tokens.
41
44
  */
42
45
 
43
46
  /*
44
- * Toast (sonner) and Drawer enter/exit motion, the entrances (rise / stagger / settled /
45
- * skeleton) and the app-shell markers, shared by both ports. Imported by the token
47
+ * Toast (sonner) and Drawer enter/exit motion, the reduced-motion fade for Sheet and
48
+ * the dialogs, the entrances (rise / stagger / settled / skeleton) and the app-shell
49
+ * markers, shared by both ports. Imported by the token
46
50
  * sheets so the lifecycle ships with the theme contract.
47
51
  */
48
52
 
@@ -348,6 +352,30 @@
348
352
  }
349
353
  }
350
354
 
355
+ /* ── Sheet and dialogs under reduced motion ──────────────────────────────────
356
+ Both ports animate these with tw-animate-css (`animate-in` / `animate-out` +
357
+ `slide-*` / `zoom-*`), whose `enter` / `exit` keyframes read the
358
+ --tw-enter-* / --tw-exit-* variables the slide and zoom utilities set. Zeroing
359
+ the travel and the scale here leaves a fade: the dialogs already fade, the
360
+ sheet does not, so its opacity is set too. The keyframes still run on their
361
+ usual clock, so the TS port's exit `animationend` unmount is untouched.
362
+ Unlayered, like the rest of this sheet, so it outranks those utilities
363
+ without !important. */
364
+ @media (prefers-reduced-motion: reduce) {
365
+ [data-slot="sheet-content"],
366
+ [data-slot="dialog-content"],
367
+ [data-slot="alert-dialog-content"] {
368
+ --tw-enter-translate-x: 0;
369
+ --tw-enter-translate-y: 0;
370
+ --tw-exit-translate-x: 0;
371
+ --tw-exit-translate-y: 0;
372
+ --tw-enter-scale: 1;
373
+ --tw-exit-scale: 1;
374
+ --tw-enter-opacity: 0;
375
+ --tw-exit-opacity: 0;
376
+ }
377
+ }
378
+
351
379
  /* ── Entrances: rise, stagger, settled, skeleton ─────────────────────────────
352
380
  One keyframe for every "arriving" thing, driven by attributes so a consumer
353
381
  without a motion library (and a Server Component) gets it for free:
@@ -554,6 +582,7 @@
554
582
  --color-on-accent-warn: var(--on-accent-warn);
555
583
  --color-accent-error: var(--accent-error);
556
584
  --color-on-accent-error: var(--on-accent-error);
585
+ --color-accent-error-ink: var(--accent-error-ink);
557
586
 
558
587
  /* charts — distinguishable hues carrying no significance */
559
588
  --color-chart-1: var(--chart-1);
@@ -581,6 +610,12 @@
581
610
  * side — ink over white needs ~47% to clear it, dark lands near 4:1. */
582
611
  --input: color-mix(in srgb, var(--ink) 48%, transparent);
583
612
  --ring: var(--primary-ink);
613
+ /* the error role as text on its own 10 % tint — a destructive menu row
614
+ * (lib#166). The fill alone read 3.76:1 there on EV and 2.48:1 on a dark
615
+ * brand; 30 % of the ink pulls it toward whichever polarity the text needs.
616
+ * Derived rather than required so a brand file that predates it still
617
+ * renders; one that misses the floor pins its own (docs/spec/accents.md). */
618
+ --accent-error-ink: color-mix(in srgb, var(--accent-error) 70%, var(--ink));
584
619
  }
585
620
 
586
621
  /* Geometry. A `[data-brand]` scope is a theme root of its own, so it restarts
@@ -729,8 +764,10 @@
729
764
  --accent-warn: #f2c94c;
730
765
  --on-accent-warn: #070d18;
731
766
  /* lifted off the brand red #da251d, which failed AA on a card. This one value
732
- * carries both the fill and the text; the vivid brand red is a gitops
733
- * constant, not a UI token. */
767
+ * is the fill and the text on a bare surface; on its own 10 % tint (a
768
+ * destructive menu row) the text is the derived --accent-error-ink, which
769
+ * EV leaves to the contract: #ec8379, 4.83:1 on that tint over popover. The
770
+ * vivid brand red is a gitops constant, not a UI token. */
734
771
  --accent-error: #ef5b52;
735
772
  --on-accent-error: #070d18;
736
773