akanjs 2.3.11-rc.9 → 2.3.11

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 (53) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/client/csrTypes.ts +2 -0
  3. package/common/routeConvention.ts +8 -5
  4. package/package.json +1 -1
  5. package/server/routeTreeBuilder.ts +37 -4
  6. package/types/client/csrTypes.d.ts +2 -0
  7. package/types/common/routeConvention.d.ts +1 -1
  8. package/types/ui/Button.d.ts +6 -1
  9. package/types/ui/DatePicker.d.ts +12 -9
  10. package/types/ui/Dropdown.d.ts +6 -1
  11. package/types/ui/Empty.d.ts +7 -1
  12. package/types/ui/Input.d.ts +12 -8
  13. package/types/ui/Loading/index.d.ts +11 -6
  14. package/types/ui/Menu.d.ts +9 -4
  15. package/types/ui/Modal.d.ts +11 -1
  16. package/types/ui/Pagination.d.ts +6 -1
  17. package/types/ui/Popconfirm.d.ts +7 -1
  18. package/types/ui/Radio.d.ts +7 -4
  19. package/types/ui/Select.d.ts +6 -1
  20. package/types/ui/Table.d.ts +6 -1
  21. package/types/ui/ToggleSelect.d.ts +11 -7
  22. package/types/ui/UiOverride/Provider.d.ts +14 -0
  23. package/types/ui/UiOverride/context.d.ts +72 -0
  24. package/types/ui/UiOverride/createOverridable.d.ts +9 -0
  25. package/types/ui/UiOverride/index.d.ts +5 -0
  26. package/types/ui/UiOverride/override.d.ts +18 -0
  27. package/types/ui/UiOverride/useUiOverride.d.ts +7 -0
  28. package/types/ui/UiOverride.d.ts +1 -0
  29. package/types/ui/Unauthorized.d.ts +8 -3
  30. package/types/ui/index.d.ts +1 -0
  31. package/ui/Button.tsx +15 -2
  32. package/ui/DatePicker.tsx +19 -9
  33. package/ui/Dropdown.tsx +9 -1
  34. package/ui/Empty.tsx +11 -1
  35. package/ui/Input.tsx +22 -14
  36. package/ui/Loading/index.tsx +15 -1
  37. package/ui/Menu.tsx +12 -3
  38. package/ui/Modal.tsx +13 -1
  39. package/ui/Pagination.tsx +9 -1
  40. package/ui/Popconfirm.tsx +9 -1
  41. package/ui/Radio.tsx +14 -3
  42. package/ui/Select.tsx +22 -2
  43. package/ui/Table.tsx +8 -1
  44. package/ui/ToggleSelect.tsx +31 -5
  45. package/ui/UiOverride/Provider.tsx +22 -0
  46. package/ui/UiOverride/context.ts +85 -0
  47. package/ui/UiOverride/createOverridable.tsx +25 -0
  48. package/ui/UiOverride/index.ts +5 -0
  49. package/ui/UiOverride/override.ts +19 -0
  50. package/ui/UiOverride/useUiOverride.ts +15 -0
  51. package/ui/Unauthorized.tsx +12 -3
  52. package/ui/index.ts +10 -0
  53. package/webkit/bootCsr.tsx +28 -5
@@ -1,9 +1,14 @@
1
1
  import type { ReactNode } from "react";
2
- interface UnauthorizedProps {
2
+ export interface UnauthorizedProps {
3
3
  className?: string;
4
4
  description?: ReactNode;
5
5
  children?: ReactNode;
6
6
  minHeight?: number;
7
7
  }
8
- export declare const Unauthorized: ({ className, description, children, minHeight }: UnauthorizedProps) => import("react/jsx-runtime").JSX.Element;
9
- export {};
8
+ export declare const DefaultUnauthorized: ({ className, description, children, minHeight }: UnauthorizedProps) => import("react/jsx-runtime").JSX.Element;
9
+ /**
10
+ * Unauthorized-state placeholder. Resolves to a route-scoped override when a
11
+ * `page/**\/_overrides.tsx` in the route's ancestry declares one, otherwise
12
+ * renders {@link DefaultUnauthorized}.
13
+ */
14
+ export declare const Unauthorized: import("react").ComponentType<UnauthorizedProps>;
@@ -41,4 +41,5 @@ export { System, type WebAppManifest } from "./System.d.ts";
41
41
  export { Tab } from "./Tab.d.ts";
42
42
  export { Table } from "./Table.d.ts";
43
43
  export { ToggleSelect } from "./ToggleSelect.d.ts";
44
+ export { type AkanModalComponent, type AkanUiOverrideName, type AkanUiOverrides, createOverridable, override, UiOverrideProvider, type UiOverrideProviderProps, useUiOverride, } from "./UiOverride.d.ts";
44
45
  export { Unauthorized } from "./Unauthorized.d.ts";
package/ui/Button.tsx CHANGED
@@ -1,9 +1,11 @@
1
1
  "use client";
2
2
  import { clsx, usePage } from "akanjs/client";
3
3
  import type React from "react";
4
- import { type ButtonHTMLAttributes, useState } from "react";
4
+ import { type ButtonHTMLAttributes, type ComponentType, createElement, useState } from "react";
5
5
  import { AiOutlineCheckCircle } from "react-icons/ai";
6
6
 
7
+ import { useUiOverride } from "./UiOverride";
8
+
7
9
  export type ButtonProps<Result> = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> & {
8
10
  /** Async-aware click handler. Call onError to show the localized error state without throwing. */
9
11
  onClick: (
@@ -14,7 +16,7 @@ export type ButtonProps<Result> = Omit<ButtonHTMLAttributes<HTMLButtonElement>,
14
16
  onSuccess?: (result: Result) => void;
15
17
  };
16
18
 
17
- export const Button = <Result = unknown>({ className, children, onClick, onSuccess, ...rest }: ButtonProps<Result>) => {
19
+ const DefaultButton = <Result = unknown>({ className, children, onClick, onSuccess, ...rest }: ButtonProps<Result>) => {
18
20
  const { l } = usePage();
19
21
  const [state, setState] = useState<{
20
22
  mode: "idle" | "loading" | "success" | "error";
@@ -68,3 +70,14 @@ export const Button = <Result = unknown>({ className, children, onClick, onSucce
68
70
  </>
69
71
  );
70
72
  };
73
+
74
+ /**
75
+ * Async-aware button. Resolves to a route-scoped override when a `page/**\/_overrides.tsx`
76
+ * in the route's ancestry declares one, otherwise renders {@link DefaultButton}. The public
77
+ * generic signature is preserved, so `<Button<Todo> onSuccess={(r: Todo) => …} />` still infers.
78
+ */
79
+ export const Button = <Result = unknown>(props: ButtonProps<Result>) => {
80
+ const Override = useUiOverride("Button");
81
+ const Impl = (Override ?? DefaultButton) as unknown as ComponentType<ButtonProps<Result>>;
82
+ return createElement(Impl, props);
83
+ };
package/ui/DatePicker.tsx CHANGED
@@ -5,6 +5,8 @@ import { lazy } from "akanjs/webkit";
5
5
  import { type FocusEvent, useEffect, useRef } from "react";
6
6
  import { AiOutlineSwapRight } from "react-icons/ai";
7
7
 
8
+ import { createOverridable } from "./UiOverride";
9
+
8
10
  const reactDatePickerPackage = "react-datepicker";
9
11
  const reactDatePickerStylePath = "react-datepicker/dist/react-datepicker.css";
10
12
 
@@ -15,7 +17,7 @@ const ReactDatePicker = lazy(
15
17
  },
16
18
  { ssr: false },
17
19
  );
18
- interface DatePickerProps {
20
+ export interface DatePickerProps {
19
21
  value?: Dayjs | null;
20
22
  onChange: (value: Dayjs | null) => void;
21
23
  showTime?: boolean;
@@ -27,7 +29,7 @@ interface DatePickerProps {
27
29
  defaultValue?: Dayjs;
28
30
  }
29
31
 
30
- export const DatePicker = ({
32
+ const DefaultDatePicker = ({
31
33
  value,
32
34
  onChange,
33
35
  showTime,
@@ -74,7 +76,7 @@ export const DatePicker = ({
74
76
  );
75
77
  };
76
78
 
77
- interface RangePickerProps {
79
+ export interface RangePickerProps {
78
80
  value: [Dayjs | null, Dayjs | null];
79
81
  onChange: (value: [Dayjs | null, Dayjs | null]) => void;
80
82
  format?: string;
@@ -84,7 +86,7 @@ interface RangePickerProps {
84
86
  className?: string;
85
87
  }
86
88
 
87
- const RangePicker = ({
89
+ const DefaultRangePicker = ({
88
90
  value,
89
91
  onChange,
90
92
  format = "yyyy-MM-dd",
@@ -143,9 +145,7 @@ const RangePicker = ({
143
145
  );
144
146
  };
145
147
 
146
- DatePicker.RangePicker = RangePicker;
147
-
148
- interface TimePickerProps {
148
+ export interface TimePickerProps {
149
149
  value: Dayjs | null;
150
150
  onChange: (value: Dayjs) => void;
151
151
  format?: string;
@@ -155,7 +155,7 @@ interface TimePickerProps {
155
155
  disabled?: boolean;
156
156
  }
157
157
 
158
- const TimePicker = ({
158
+ const DefaultTimePicker = ({
159
159
  disabled,
160
160
  className,
161
161
  value,
@@ -187,5 +187,15 @@ const TimePicker = ({
187
187
  );
188
188
  };
189
189
 
190
- DatePicker.TimePicker = TimePicker;
190
+ const DatePickerBase = createOverridable("DatePicker", DefaultDatePicker);
191
+
192
+ /**
193
+ * Date picker. `DatePicker`, `DatePicker.RangePicker`, and `DatePicker.TimePicker` each resolve to a
194
+ * route-scoped override when a `page/**\/_overrides.tsx` in the route's ancestry declares one (slots
195
+ * `DatePicker`, `DatePickerRangePicker`, `DatePickerTimePicker`).
196
+ */
197
+ export const DatePicker = Object.assign(DatePickerBase, {
198
+ RangePicker: createOverridable("DatePickerRangePicker", DefaultRangePicker),
199
+ TimePicker: createOverridable("DatePickerTimePicker", DefaultTimePicker),
200
+ });
191
201
 
package/ui/Dropdown.tsx CHANGED
@@ -2,6 +2,8 @@
2
2
  import { clsx } from "akanjs/client";
3
3
  import { type ReactNode, useState } from "react";
4
4
 
5
+ import { createOverridable } from "./UiOverride";
6
+
5
7
  export interface DropdownProps {
6
8
  /** Button/trigger content. */
7
9
  value: ReactNode;
@@ -15,7 +17,7 @@ export interface DropdownProps {
15
17
  dropdownClassName?: string;
16
18
  }
17
19
 
18
- export const Dropdown = ({ value, content, className, buttonClassName, dropdownClassName }: DropdownProps) => {
20
+ export const DefaultDropdown = ({ value, content, className, buttonClassName, dropdownClassName }: DropdownProps) => {
19
21
  const [opened, setOpened] = useState(false);
20
22
  return (
21
23
  <div
@@ -49,3 +51,9 @@ export const Dropdown = ({ value, content, className, buttonClassName, dropdownC
49
51
  </div>
50
52
  );
51
53
  };
54
+
55
+ /**
56
+ * Dropdown. Resolves to a route-scoped override when a `page/**\/_overrides.tsx`
57
+ * in the route's ancestry declares one, otherwise renders {@link DefaultDropdown}.
58
+ */
59
+ export const Dropdown = createOverridable("Dropdown", DefaultDropdown);
package/ui/Empty.tsx CHANGED
@@ -1,7 +1,10 @@
1
+ "use client";
1
2
  import { clsx, usePage } from "akanjs/client";
2
3
  import type { ReactNode } from "react";
3
4
  import { AiOutlineMeh } from "react-icons/ai";
4
5
 
6
+ import { createOverridable } from "./UiOverride";
7
+
5
8
  export interface EmptyProps {
6
9
  /** Additional classes for the empty-state body. */
7
10
  className?: string;
@@ -13,7 +16,7 @@ export interface EmptyProps {
13
16
  minHeight?: number;
14
17
  }
15
18
 
16
- export const Empty = ({ className = "", description, children, minHeight = 300 }: EmptyProps) => {
19
+ export const DefaultEmpty = ({ className = "", description, children, minHeight = 300 }: EmptyProps) => {
17
20
  const { l } = usePage();
18
21
  return (
19
22
  <div>
@@ -30,3 +33,10 @@ export const Empty = ({ className = "", description, children, minHeight = 300 }
30
33
  </div>
31
34
  );
32
35
  };
36
+
37
+ /**
38
+ * Empty-state placeholder. Resolves to a route-scoped override when a
39
+ * `page/**\/_overrides.tsx` in the route's ancestry declares one, otherwise
40
+ * renders {@link DefaultEmpty}.
41
+ */
42
+ export const Empty = createOverridable("Empty", DefaultEmpty);
package/ui/Input.tsx CHANGED
@@ -13,6 +13,8 @@ import React, {
13
13
  } from "react";
14
14
  import { AiOutlineEye, AiOutlineEyeInvisible } from "react-icons/ai";
15
15
 
16
+ import { createOverridable } from "./UiOverride";
17
+
16
18
  export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange"> & {
17
19
  /** Visual input style. */
18
20
  inputStyleType?: "bordered" | "borderless" | "underline";
@@ -38,7 +40,7 @@ export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "
38
40
  /** Called when Escape is pressed after blurring the input. */
39
41
  onPressEscape?: (e: KeyboardEvent<HTMLInputElement>) => void;
40
42
  };
41
- export const Input = ({
43
+ const DefaultInput = ({
42
44
  className,
43
45
  nullable,
44
46
  inputRef,
@@ -145,7 +147,7 @@ export type TextAreaProps = Omit<
145
147
  validate: (value: string) => boolean | string;
146
148
  onPressEscape?: (e: KeyboardEvent<HTMLTextAreaElement>) => void;
147
149
  };
148
- const TextArea = ({
150
+ const DefaultTextArea = ({
149
151
  className,
150
152
  nullable,
151
153
  value,
@@ -218,7 +220,6 @@ const TextArea = ({
218
220
  </div>
219
221
  );
220
222
  };
221
- Input.TextArea = TextArea;
222
223
 
223
224
  export type PasswordProps = Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "type" | "onChange"> & {
224
225
  value: string;
@@ -233,7 +234,7 @@ export type PasswordProps = Omit<InputHTMLAttributes<HTMLInputElement>, "value"
233
234
  onChange?: (value: string, e?: ChangeEvent<HTMLInputElement>) => void;
234
235
  validate: (value: string) => boolean | string;
235
236
  };
236
- const Password = ({
237
+ const DefaultPassword = ({
237
238
  className,
238
239
  nullable,
239
240
  value,
@@ -330,8 +331,6 @@ const Password = ({
330
331
  );
331
332
  };
332
333
 
333
- Input.Password = Password;
334
-
335
334
  export type EmailProps = Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "type" | "onChange"> & {
336
335
  inputStyleType?: "bordered" | "borderless" | "underline";
337
336
  value: string;
@@ -346,7 +345,7 @@ export type EmailProps = Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "
346
345
  onChange?: (value: string, e?: ChangeEvent<HTMLInputElement>) => void;
347
346
  validate: (value: string) => boolean | string;
348
347
  };
349
- const Email = ({
348
+ const DefaultEmail = ({
350
349
  inputStyleType = "bordered",
351
350
  className,
352
351
  nullable,
@@ -445,8 +444,6 @@ const Email = ({
445
444
  );
446
445
  };
447
446
 
448
- Input.Email = Email;
449
-
450
447
  export type NumberProps = Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "type" | "onChange"> & {
451
448
  value: number | null;
452
449
  nullable?: boolean;
@@ -464,7 +461,7 @@ export type NumberProps = Omit<InputHTMLAttributes<HTMLInputElement>, "value" |
464
461
  formatter?: (value: string) => string;
465
462
  parser?: (value: string) => string;
466
463
  };
467
- const Number = ({
464
+ const DefaultNumber = ({
468
465
  className,
469
466
  nullable,
470
467
  value,
@@ -612,15 +609,13 @@ const Number = ({
612
609
  );
613
610
  };
614
611
 
615
- Input.Number = Number;
616
-
617
612
  export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, "onChange"> & {
618
613
  className?: string;
619
614
  checked: boolean;
620
615
  onChange: (checked: boolean, e: ChangeEvent<HTMLInputElement>) => void;
621
616
  };
622
617
 
623
- const Checkbox = ({ checked, onChange, className, ...rest }: CheckboxProps) => {
618
+ const DefaultCheckbox = ({ checked, onChange, className, ...rest }: CheckboxProps) => {
624
619
  return (
625
620
  <input
626
621
  {...rest}
@@ -633,4 +628,17 @@ const Checkbox = ({ checked, onChange, className, ...rest }: CheckboxProps) => {
633
628
  />
634
629
  );
635
630
  };
636
- Input.Checkbox = Checkbox;
631
+ const InputBase = createOverridable("Input", DefaultInput);
632
+
633
+ /**
634
+ * Text input plus its field variants. Each leaf resolves to a route-scoped override when a
635
+ * `page/**\/_overrides.tsx` in the route's ancestry declares one (slots `Input`, `InputTextArea`,
636
+ * `InputPassword`, `InputEmail`, `InputNumber`, `InputCheckbox`), otherwise renders the default.
637
+ */
638
+ export const Input = Object.assign(InputBase, {
639
+ TextArea: createOverridable("InputTextArea", DefaultTextArea),
640
+ Password: createOverridable("InputPassword", DefaultPassword),
641
+ Email: createOverridable("InputEmail", DefaultEmail),
642
+ Number: createOverridable("InputNumber", DefaultNumber),
643
+ Checkbox: createOverridable("InputCheckbox", DefaultCheckbox),
644
+ });
@@ -1,3 +1,5 @@
1
+ "use client";
2
+ import { createOverridable } from "../UiOverride";
1
3
  import { Area } from "./Area";
2
4
  import { Button } from "./Button";
3
5
  import { Input } from "./Input";
@@ -5,4 +7,16 @@ import { ProgressBar } from "./ProgressBar";
5
7
  import { Skeleton } from "./Skeleton";
6
8
  import { Spin } from "./Spin";
7
9
 
8
- export const Loading = { Area, Button, Input, ProgressBar, Skeleton, Spin };
10
+ /**
11
+ * Loading indicators. Each member is an independent override slot (`LoadingSpin`, `LoadingSkeleton`,
12
+ * `LoadingProgressBar`, `LoadingButton`, `LoadingInput`, `LoadingArea`), resolved from the closest
13
+ * `page/**\/_overrides.tsx` in the route's ancestry, otherwise the shipped default.
14
+ */
15
+ export const Loading = {
16
+ Area: createOverridable("LoadingArea", Area),
17
+ Button: createOverridable("LoadingButton", Button),
18
+ Input: createOverridable("LoadingInput", Input),
19
+ ProgressBar: createOverridable("LoadingProgressBar", ProgressBar),
20
+ Skeleton: createOverridable("LoadingSkeleton", Skeleton),
21
+ Spin: createOverridable("LoadingSpin", Spin),
22
+ };
package/ui/Menu.tsx CHANGED
@@ -4,7 +4,9 @@ import { st } from "akanjs/store";
4
4
  import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
5
5
  import { AiFillCaretDown, AiOutlineEllipsis } from "react-icons/ai";
6
6
 
7
- interface MenuItem {
7
+ import { createOverridable } from "./UiOverride";
8
+
9
+ export interface MenuItem {
8
10
  label: ReactNode;
9
11
  key: string;
10
12
  children?: MenuItem[];
@@ -12,7 +14,7 @@ interface MenuItem {
12
14
  type?: string;
13
15
  }
14
16
 
15
- interface MenuProps {
17
+ export interface MenuProps {
16
18
  className?: string;
17
19
  ulClassName?: string;
18
20
  liClassName?: string;
@@ -29,7 +31,7 @@ interface MenuProps {
29
31
  onMouseLeave?: () => void;
30
32
  }
31
33
 
32
- export const Menu = ({
34
+ export const DefaultMenu = ({
33
35
  items,
34
36
  onClick,
35
37
  selectedKeys,
@@ -271,3 +273,10 @@ const OverflowMenu = ({ overflowItems, onClick }: OverflowMenuProps) => {
271
273
  </li>
272
274
  );
273
275
  };
276
+
277
+ /**
278
+ * Navigation menu. Resolves to a route-scoped override when a
279
+ * `page/**\/_overrides.tsx` in the route's ancestry declares one, otherwise
280
+ * renders {@link DefaultMenu}.
281
+ */
282
+ export const Menu = createOverridable("Menu", DefaultMenu);
package/ui/Modal.tsx CHANGED
@@ -2,6 +2,7 @@
2
2
  import type { ReactNode } from "react";
3
3
 
4
4
  import { Dialog } from "./Dialog";
5
+ import { createOverridable } from "./UiOverride";
5
6
 
6
7
  export interface ModalProps {
7
8
  /** Additional classes for the modal surface. */
@@ -21,7 +22,11 @@ export interface ModalProps {
21
22
  confirmClose?: boolean;
22
23
  }
23
24
 
24
- export const Modal = ({
25
+ /**
26
+ * Default akanjs modal skin. Kept as the fallback implementation; apps replace
27
+ * it per-route through a `page/**\/_overrides.tsx` manifest.
28
+ */
29
+ export const DefaultModal = ({
25
30
  className,
26
31
  title,
27
32
  action,
@@ -41,3 +46,10 @@ export const Modal = ({
41
46
  </Dialog>
42
47
  );
43
48
  };
49
+
50
+ /**
51
+ * Public Modal. Resolves to a route-scoped override when a `_overrides.tsx` in
52
+ * the route's ancestry declares one, otherwise renders {@link DefaultModal}.
53
+ * The proxy is transparent to every existing `<Modal … />` call site.
54
+ */
55
+ export const Modal = createOverridable("Modal", DefaultModal);
package/ui/Pagination.tsx CHANGED
@@ -3,6 +3,8 @@ import { clsx } from "akanjs/client";
3
3
  import type { FC, ReactNode } from "react";
4
4
  import { BiChevronLeft, BiChevronRight, BiDotsHorizontalRounded } from "react-icons/bi";
5
5
 
6
+ import { createOverridable } from "./UiOverride";
7
+
6
8
  export interface PaginationProps {
7
9
  /** Current 1-based page number. */
8
10
  currentPage: number;
@@ -22,7 +24,7 @@ export interface PaginationProps {
22
24
  };
23
25
  }
24
26
 
25
- export const Pagination: FC<PaginationProps> = ({
27
+ export const DefaultPagination: FC<PaginationProps> = ({
26
28
  currentPage,
27
29
  total,
28
30
  onPageSelect,
@@ -118,3 +120,9 @@ export const Pagination: FC<PaginationProps> = ({
118
120
  </div>
119
121
  );
120
122
  };
123
+
124
+ /**
125
+ * Pager. Resolves to a route-scoped override when a `page/**\/_overrides.tsx` in
126
+ * the route's ancestry declares one, otherwise renders {@link DefaultPagination}.
127
+ */
128
+ export const Pagination = createOverridable("Pagination", DefaultPagination);
package/ui/Popconfirm.tsx CHANGED
@@ -6,6 +6,7 @@ import { type ButtonHTMLAttributes, type ReactNode, useEffect, useState } from "
6
6
  import { BiMessageRoundedError } from "react-icons/bi";
7
7
 
8
8
  import { animated } from "./animated";
9
+ import { createOverridable } from "./UiOverride";
9
10
 
10
11
  type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
11
12
  loading?: boolean;
@@ -34,7 +35,7 @@ export interface PopconfirmProps {
34
35
  decoClassName?: string;
35
36
  }
36
37
 
37
- export const Popconfirm = ({
38
+ export const DefaultPopconfirm = ({
38
39
  title,
39
40
  description,
40
41
  onConfirm,
@@ -133,3 +134,10 @@ export const Popconfirm = ({
133
134
  </>
134
135
  );
135
136
  };
137
+
138
+ /**
139
+ * Confirmation popover. Resolves to a route-scoped override when a
140
+ * `page/**\/_overrides.tsx` in the route's ancestry declares one, otherwise
141
+ * renders {@link DefaultPopconfirm}.
142
+ */
143
+ export const Popconfirm = createOverridable("Popconfirm", DefaultPopconfirm);
package/ui/Radio.tsx CHANGED
@@ -2,6 +2,8 @@
2
2
  import { clsx } from "akanjs/client";
3
3
  import type { ReactElement, ReactNode } from "react";
4
4
 
5
+ import { createOverridable } from "./UiOverride";
6
+
5
7
  export interface RadioProps {
6
8
  value: string | number | null;
7
9
  className?: string;
@@ -9,7 +11,7 @@ export interface RadioProps {
9
11
  children: ReactNode | ReactElement | ReactElement[];
10
12
  onChange: (value: string | number | null, idx: number) => void;
11
13
  }
12
- export const Radio = ({ value, children, disabled, className, onChange }: RadioProps) => {
14
+ const DefaultRadio = ({ value, children, disabled, className, onChange }: RadioProps) => {
13
15
  return (
14
16
  <div className={clsx(`flex gap-2`, className)}>
15
17
  {(children as ReactElement<{ value: string | number | null }>[]).map((child, idx) => {
@@ -47,7 +49,16 @@ export interface ItemProps {
47
49
  onChange?: (value: string) => void;
48
50
  }
49
51
 
50
- const Item = ({ value, className, children }: ItemProps) => {
52
+ const DefaultItem = ({ value, className, children }: ItemProps) => {
51
53
  return <div className={clsx("", className)}>{children}</div>;
52
54
  };
53
- Radio.Item = Item;
55
+
56
+ const RadioBase = createOverridable("Radio", DefaultRadio);
57
+
58
+ /**
59
+ * Radio group. `Radio` and `Radio.Item` each resolve to a route-scoped override when a
60
+ * `page/**\/_overrides.tsx` in the route's ancestry declares one (slots `Radio`, `RadioItem`).
61
+ */
62
+ export const Radio = Object.assign(RadioBase, {
63
+ Item: createOverridable("RadioItem", DefaultItem),
64
+ });
package/ui/Select.tsx CHANGED
@@ -2,11 +2,13 @@
2
2
  import { type Cls, type EnumInstance, isEnum } from "akanjs/base";
3
3
  import { clsx, usePage } from "akanjs/client";
4
4
  import { useDebounce } from "akanjs/webkit";
5
- import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
5
+ import { type ComponentType, createElement, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
6
6
  import { BiCheck, BiChevronDown, BiX } from "react-icons/bi";
7
7
  import { BsQuestionCircleFill } from "react-icons/bs";
8
8
  import { TiDelete } from "react-icons/ti";
9
9
 
10
+ import { useUiOverride } from "./UiOverride";
11
+
10
12
  interface LabelOption<T> {
11
13
  label: string | boolean | number;
12
14
  value: T;
@@ -53,7 +55,7 @@ export interface SelectProps<
53
55
  renderSelected?: (value: T) => ReactNode;
54
56
  }
55
57
 
56
- export const Select = <
58
+ const DefaultSelect = <
57
59
  T extends string | number | boolean | null | undefined,
58
60
  Multiple extends boolean = false,
59
61
  Searchable extends boolean = false,
@@ -320,3 +322,21 @@ export const Select = <
320
322
  </div>
321
323
  );
322
324
  };
325
+
326
+ /**
327
+ * Select. Resolves to a route-scoped override when a `page/**\/_overrides.tsx` in the route's
328
+ * ancestry declares one, otherwise renders {@link DefaultSelect}. The public generic signature is
329
+ * preserved, so `<Select<MyEnum, true> …/>` still infers the value/onChange shape.
330
+ */
331
+ export const Select = <
332
+ T extends string | number | boolean | null | undefined,
333
+ Multiple extends boolean = false,
334
+ Searchable extends boolean = false,
335
+ Option extends Options<T> = Options<T>,
336
+ >(
337
+ props: SelectProps<T, Multiple, Searchable, Option>,
338
+ ) => {
339
+ const Override = useUiOverride("Select");
340
+ const Impl = (Override ?? DefaultSelect) as unknown as ComponentType<SelectProps<T, Multiple, Searchable, Option>>;
341
+ return createElement(Impl, props);
342
+ };
package/ui/Table.tsx CHANGED
@@ -8,6 +8,7 @@ import { AiOutlineLoading3Quarters } from "react-icons/ai";
8
8
 
9
9
  import { Empty } from "./Empty";
10
10
  import { Pagination, type PaginationProps } from "./Pagination";
11
+ import { createOverridable } from "./UiOverride";
11
12
 
12
13
  export interface Column {
13
14
  /** Stable column key. Defaults to index when omitted. */
@@ -45,7 +46,7 @@ export interface TableProps {
45
46
  rowKey?: (model: any) => string;
46
47
  }
47
48
 
48
- export const Table = ({
49
+ export const DefaultTable = ({
49
50
  columns,
50
51
  dataSource,
51
52
  loading,
@@ -129,3 +130,9 @@ export const Table = ({
129
130
  </div>
130
131
  );
131
132
  };
133
+
134
+ /**
135
+ * Data table. Resolves to a route-scoped override when a `page/**\/_overrides.tsx`
136
+ * in the route's ancestry declares one, otherwise renders {@link DefaultTable}.
137
+ */
138
+ export const Table = createOverridable("Table", DefaultTable);
@@ -1,6 +1,10 @@
1
+ "use client";
1
2
  import { clsx, usePage } from "akanjs/client";
3
+ import { type ComponentType, createElement } from "react";
2
4
 
3
- interface ToggleSelectProps<I extends string | number | boolean | null> {
5
+ import { createOverridable, useUiOverride } from "./UiOverride";
6
+
7
+ export interface ToggleSelectProps<I extends string | number | boolean | null> {
4
8
  className?: string;
5
9
  btnClassName?: string;
6
10
  items: string[] | number[] | { label: string; value: I; disabled?: boolean }[];
@@ -10,7 +14,7 @@ interface ToggleSelectProps<I extends string | number | boolean | null> {
10
14
  onChange: (value: I, idx: number) => void;
11
15
  disabled?: boolean;
12
16
  }
13
- export const ToggleSelect = <I extends string | number | boolean | null>({
17
+ const DefaultToggleSelect = <I extends string | number | boolean | null>({
14
18
  className,
15
19
  btnClassName,
16
20
  items,
@@ -72,7 +76,7 @@ export const ToggleSelect = <I extends string | number | boolean | null>({
72
76
  );
73
77
  };
74
78
 
75
- interface MultiProps {
79
+ export interface MultiProps {
76
80
  className?: string;
77
81
  btnClassName?: string;
78
82
  items: string[] | number[] | { label: string; value: string | number; disabled?: boolean }[];
@@ -82,7 +86,16 @@ interface MultiProps {
82
86
  onChange: (value: string[] | number[]) => void;
83
87
  disabled?: boolean;
84
88
  }
85
- const Multi = ({ className, btnClassName, items, nullable, validate, value, onChange, disabled }: MultiProps) => {
89
+ const DefaultMulti = ({
90
+ className,
91
+ btnClassName,
92
+ items,
93
+ nullable,
94
+ validate,
95
+ value,
96
+ onChange,
97
+ disabled,
98
+ }: MultiProps) => {
86
99
  const { l } = usePage();
87
100
  const validateResult = validate(value);
88
101
 
@@ -141,4 +154,17 @@ const Multi = ({ className, btnClassName, items, nullable, validate, value, onCh
141
154
  </div>
142
155
  );
143
156
  };
144
- ToggleSelect.Multi = Multi;
157
+ const ToggleSelectBase = <I extends string | number | boolean | null>(props: ToggleSelectProps<I>) => {
158
+ const Override = useUiOverride("ToggleSelect");
159
+ const Impl = (Override ?? DefaultToggleSelect) as unknown as ComponentType<ToggleSelectProps<I>>;
160
+ return createElement(Impl, props);
161
+ };
162
+
163
+ /**
164
+ * Toggle-select. `ToggleSelect` keeps its generic signature (so `<ToggleSelect<Status> …/>` still
165
+ * infers), and both it and `ToggleSelect.Multi` resolve to a route-scoped override when a
166
+ * `page/**\/_overrides.tsx` in the route's ancestry declares one (slots `ToggleSelect`, `ToggleSelectMulti`).
167
+ */
168
+ export const ToggleSelect = Object.assign(ToggleSelectBase, {
169
+ Multi: createOverridable("ToggleSelectMulti", DefaultMulti),
170
+ });
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ import { type ReactNode, useContext, useMemo } from "react";
3
+
4
+ import { type AkanUiOverrides, UiOverrideContext } from "./context";
5
+
6
+ export interface UiOverrideProviderProps {
7
+ /** Override map for this subtree; merged over any ancestor overrides. */
8
+ value?: Partial<AkanUiOverrides>;
9
+ children?: ReactNode;
10
+ }
11
+
12
+ /**
13
+ * Supplies route-scoped UI overrides. Merges its own `value` over the overrides
14
+ * inherited from ancestors so the closest declaration wins, matching nested
15
+ * `_overrides.tsx` resolution. `routeTreeBuilder` mounts one of these per route
16
+ * node once the `_overrides.tsx` convention is wired.
17
+ */
18
+ export const UiOverrideProvider = ({ value, children }: UiOverrideProviderProps) => {
19
+ const parent = useContext(UiOverrideContext);
20
+ const merged = useMemo(() => ({ ...parent, ...value }), [parent, value]);
21
+ return <UiOverrideContext.Provider value={merged}>{children}</UiOverrideContext.Provider>;
22
+ };