@ahrowe/ui 0.26.0 → 0.28.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 (90) hide show
  1. package/dist/esm/common/accordion/accordion.mjs +1 -1
  2. package/dist/esm/common/accordion/accordion.mjs.map +1 -1
  3. package/dist/esm/common/colorPicker/colorPicker.mjs +1 -1
  4. package/dist/esm/common/colorPicker/colorPicker.mjs.map +1 -1
  5. package/dist/esm/common/confirmModal/confirmModal.mjs +1 -1
  6. package/dist/esm/common/confirmModal/confirmModal.mjs.map +1 -1
  7. package/dist/esm/common/dropZone/dropZone.mjs +1 -1
  8. package/dist/esm/common/dropZone/dropZone.mjs.map +1 -1
  9. package/dist/esm/common/floatingMenu/useFloatingPosition.mjs +1 -1
  10. package/dist/esm/common/floatingMenu/useFloatingPosition.mjs.map +1 -1
  11. package/dist/esm/common/floorPlan/floorPlan.geometry.mjs +1 -1
  12. package/dist/esm/common/floorPlan/floorPlan.geometry.mjs.map +1 -1
  13. package/dist/esm/common/floorPlan/floorPlan.graph.mjs +1 -1
  14. package/dist/esm/common/floorPlan/floorPlan.graph.mjs.map +1 -1
  15. package/dist/esm/common/floorPlan/floorPlan.mesh.mjs +2 -0
  16. package/dist/esm/common/floorPlan/floorPlan.mesh.mjs.map +1 -0
  17. package/dist/esm/common/floorPlan/floorPlan.triangulate.mjs +2 -0
  18. package/dist/esm/common/floorPlan/floorPlan.triangulate.mjs.map +1 -0
  19. package/dist/esm/common/hooks/useAnchorTracking.mjs +2 -0
  20. package/dist/esm/common/hooks/useAnchorTracking.mjs.map +1 -0
  21. package/dist/esm/common/idleManager/idleManager.mjs +1 -1
  22. package/dist/esm/common/idleManager/idleManager.mjs.map +1 -1
  23. package/dist/esm/common/input/input.mjs +1 -1
  24. package/dist/esm/common/input/input.mjs.map +1 -1
  25. package/dist/esm/common/inputDropdown/inputDropdown.mjs +1 -1
  26. package/dist/esm/common/inputDropdown/inputDropdown.mjs.map +1 -1
  27. package/dist/esm/common/klipyPicker/components/gifView/gifView.mjs +1 -1
  28. package/dist/esm/common/klipyPicker/components/gifView/gifView.mjs.map +1 -1
  29. package/dist/esm/common/multiSelect/multiSelect.mjs +2 -0
  30. package/dist/esm/common/multiSelect/multiSelect.mjs.map +1 -0
  31. package/dist/esm/common/multiSelect/multiSelect.module.mjs +2 -0
  32. package/dist/esm/common/multiSelect/multiSelect.module.mjs.map +1 -0
  33. package/dist/esm/common/overscroll/overscroll.mjs +1 -1
  34. package/dist/esm/common/overscroll/overscroll.mjs.map +1 -1
  35. package/dist/esm/common/pagination/pagination.mjs +2 -0
  36. package/dist/esm/common/pagination/pagination.mjs.map +1 -0
  37. package/dist/esm/common/pagination/pagination.module.mjs +2 -0
  38. package/dist/esm/common/pagination/pagination.module.mjs.map +1 -0
  39. package/dist/esm/common/popover/usePopoverPosition.mjs +1 -1
  40. package/dist/esm/common/popover/usePopoverPosition.mjs.map +1 -1
  41. package/dist/esm/common/roomDrawer/roomDrawer.mjs +1 -1
  42. package/dist/esm/common/roomDrawer/roomDrawer.mjs.map +1 -1
  43. package/dist/esm/common/roomDrawer/roomDrawer.module.mjs.map +1 -1
  44. package/dist/esm/common/roomViewer/roomViewer.module.mjs.map +1 -1
  45. package/dist/esm/common/timer/timer.mjs.map +1 -1
  46. package/dist/esm/common/tree/flattenTree.mjs +2 -0
  47. package/dist/esm/common/tree/flattenTree.mjs.map +1 -0
  48. package/dist/esm/common/tree/tree.mjs +2 -0
  49. package/dist/esm/common/tree/tree.mjs.map +1 -0
  50. package/dist/esm/common/tree/tree.module.mjs +2 -0
  51. package/dist/esm/common/tree/tree.module.mjs.map +1 -0
  52. package/dist/esm/common/virtualList/virtualList.mjs +1 -1
  53. package/dist/esm/common/virtualList/virtualList.mjs.map +1 -1
  54. package/dist/esm/common/virtualList/virtualRow.mjs.map +1 -1
  55. package/dist/esm/index.mjs +1 -1
  56. package/dist/esm/services/formValidation/validatableComponent.mjs.map +1 -1
  57. package/dist/index.cjs +3 -3
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/style.css +1 -1
  60. package/dist/types/package/common/configProvider/configProvider.types.d.ts +6 -0
  61. package/dist/types/package/common/floatingMenu/useFloatingPosition.d.ts +4 -6
  62. package/dist/types/package/common/floorPlan/floorPlan.graph.d.ts +45 -0
  63. package/dist/types/package/common/floorPlan/floorPlan.mesh.d.ts +36 -0
  64. package/dist/types/package/common/floorPlan/floorPlan.triangulate.d.ts +11 -0
  65. package/dist/types/package/common/floorPlan/index.d.ts +4 -2
  66. package/dist/types/package/common/hooks/useAnchorTracking.d.ts +40 -0
  67. package/dist/types/package/common/inputDropdown/inputDropdown.types.d.ts +6 -0
  68. package/dist/types/package/common/multiSelect/index.d.ts +2 -0
  69. package/dist/types/package/common/multiSelect/multiSelect.d.ts +4 -0
  70. package/dist/types/package/common/multiSelect/multiSelect.types.d.ts +62 -0
  71. package/dist/types/package/common/pagination/index.d.ts +2 -0
  72. package/dist/types/package/common/pagination/pagination.d.ts +4 -0
  73. package/dist/types/package/common/pagination/pagination.types.d.ts +30 -0
  74. package/dist/types/package/common/popover/usePopoverPosition.d.ts +4 -2
  75. package/dist/types/package/common/tree/flattenTree.d.ts +3 -0
  76. package/dist/types/package/common/tree/index.d.ts +3 -0
  77. package/dist/types/package/common/tree/tree.d.ts +4 -0
  78. package/dist/types/package/common/tree/tree.types.d.ts +62 -0
  79. package/dist/types/package/common/virtualList/virtualList.types.d.ts +8 -0
  80. package/dist/types/package/common/virtualList/virtualRow.d.ts +1 -1
  81. package/dist/types/package/index.d.ts +6 -0
  82. package/docs/CLAUDE.md +3 -0
  83. package/docs/FloorPlan.md +39 -1
  84. package/docs/InputDropdown.md +3 -0
  85. package/docs/MultiSelect.md +100 -0
  86. package/docs/Pagination.md +72 -0
  87. package/docs/RoomDrawer.md +14 -0
  88. package/docs/Tree.md +95 -0
  89. package/docs/VirtualList.md +1 -0
  90. package/package.json +4 -2
@@ -29,6 +29,9 @@ import { FlipProps } from '../flip/flip.types';
29
29
  import { OptionPickerProps } from '../optionPicker/optionPicker.types';
30
30
  import { TimerProps } from '../timer/timer.types';
31
31
  import { OtpInputProps } from '../otpInput/otpInput.types';
32
+ import { MultiSelectProps } from '../multiSelect/multiSelect.types';
33
+ import { PaginationProps } from '../pagination/pagination.types';
34
+ import { TreeProps } from '../tree/tree.types';
32
35
  import { RoomDrawerProps } from '../roomDrawer/roomDrawer.types';
33
36
  import { RoomViewerProps } from '../roomViewer/roomViewer.types';
34
37
  import { FloatingMenuProps, Presentation } from '../floatingMenu/floatingMenu.types';
@@ -79,6 +82,9 @@ export interface ComponentDefaults {
79
82
  OptionPicker?: Partial<OptionPickerProps>;
80
83
  Timer?: Partial<TimerProps>;
81
84
  OtpInput?: Partial<OtpInputProps>;
85
+ MultiSelect?: Partial<MultiSelectProps>;
86
+ Pagination?: Partial<PaginationProps>;
87
+ Tree?: Partial<TreeProps>;
82
88
  RoomDrawer?: Partial<RoomDrawerProps>;
83
89
  RoomViewer?: Partial<RoomViewerProps>;
84
90
  FloatingMenu?: Partial<FloatingMenuProps>;
@@ -13,17 +13,15 @@ export interface UseFloatingPositionArgs {
13
13
  placement?: 'top' | 'bottom';
14
14
  }
15
15
  /**
16
- * Keeps a portaled, `position: fixed` popup anchored to a trigger element: tracks the
17
- * trigger across scroll/resize on every real scrolling ancestor, flips above the trigger
18
- * when there's no room below, and hides (not closes) the popup when the trigger itself
16
+ * Keeps a portaled, `position: fixed` popup anchored to a trigger element: flips above the
17
+ * trigger when there's no room below, and hides (not closes) the popup when the trigger itself
19
18
  * scrolls behind a clipping ancestor — matching Floating UI's autoUpdate + hide middleware.
20
19
  * It also shifts the popup horizontally to stay within the viewport: `align`/RTL-style CSS
21
20
  * only positions the panel relative to the anchor's own box and has no awareness of where
22
21
  * that box actually sits on screen, so a trigger near a screen edge would otherwise let the
23
22
  * panel overflow it with no correction.
24
23
  *
25
- * Position/visibility are written straight to the DOM via refs, not through React state:
26
- * going through setState -> re-render -> commit adds a round-trip that lags behind the
27
- * browser's own scroll painting by at least a frame.
24
+ * `useAnchorTracking` supplies the lifecycle around this: mount wait, ancestor resolution, and
25
+ * re-running on scroll, viewport resize and panel size changes.
28
26
  */
29
27
  export declare function useFloatingPosition({ isOpen, triggerRef, anchorRef, panelRef, flipClassName, placement }: UseFloatingPositionArgs): void;
@@ -94,6 +94,51 @@ export interface DissolveNodeOptions {
94
94
  export declare function dissolveNode(floor: PlanFloor, nodeId: string, options?: DissolveNodeOptions): PlanEdit<{
95
95
  wallId: string;
96
96
  } | null>;
97
+ export interface DetachWallResult {
98
+ /** The wall's end nodes after the detach, in `[a, b]` order. */
99
+ nodeIds: [string, string];
100
+ /** Connecting walls minted where an end was shared with a neighbour. */
101
+ connectorIds: string[];
102
+ /** Ends that became a straight seam once the wall left, and were dissolved away. */
103
+ dissolvedNodeIds: string[];
104
+ }
105
+ /**
106
+ * Pulls a wall off the nodes it shares with its neighbours and moves it by `delta`,
107
+ * leaving a connecting wall behind at each end that was shared.
108
+ *
109
+ * This is the extrude half of dragging a wall. A plain drag moves the shared end nodes,
110
+ * so everything meeting the wall follows it; here the neighbours keep their ends and
111
+ * only the new connectors grow, which is what you want when a partition joins the wall
112
+ * halfway along and should stay put.
113
+ *
114
+ * A free end is only moved: there is nothing there to detach from, and a connector would
115
+ * be zero length. For the same reason `delta` must be at least `MIN_WALL_LENGTH` long —
116
+ * below that the caller should `moveNode` both ends instead.
117
+ *
118
+ * The connector continues whatever was already at the corner (its thickness, type and
119
+ * heights) when exactly one wall was, so pulling a wall off a plain corner redraws
120
+ * exactly as a plain drag would: the seam that leaves behind is straight and is
121
+ * dissolved before returning. At a junction there is no single wall to continue, so the
122
+ * connector takes the dragged wall's own construction.
123
+ */
124
+ export declare function detachWall(floor: PlanFloor, wallId: string, delta: PlanPoint): PlanEdit<DetachWallResult | null>;
125
+ /**
126
+ * Collapses every wall shorter than `minLength` by merging its two ends together.
127
+ *
128
+ * Squashing a wall to nothing is the one thing a drag can do that the model forbids, and
129
+ * it is easy to do by accident: pushing a wall back onto the line it was detached from,
130
+ * or dragging a corner past the one next to it. Deleting the short wall is the wrong
131
+ * repair — it leaves two nodes sitting on the same point with the plan split between
132
+ * them, which reads as two corners stacked on top of each other and derives as two
133
+ * rooms. Merging is lossless, since the two ends are within `minLength` of each other by
134
+ * definition.
135
+ *
136
+ * The end with more walls on it survives, so the corner that was already there keeps its
137
+ * position and the one that arrived is the one that gives way.
138
+ */
139
+ export declare function weldShortWalls(floor: PlanFloor, minLength?: number): PlanEdit<{
140
+ weldedWallIds: string[];
141
+ }>;
97
142
  export declare function removeWall(floor: PlanFloor, wallId: string, opts?: {
98
143
  pruneOrphanNodes?: boolean;
99
144
  dissolveCollinear?: boolean;
@@ -0,0 +1,36 @@
1
+ import { PlanFloor } from './floorPlan.types';
2
+ export type PlanMeshPart = 'wall' | 'floor';
3
+ export interface PlanMeshGroup {
4
+ part: PlanMeshPart;
5
+ /** Offset into `indices`, and how many of them. Draw ranges, or assign materials. */
6
+ start: number;
7
+ count: number;
8
+ }
9
+ export interface PlanMesh {
10
+ /** World-space xyz triples, in millimetres. */
11
+ positions: Float32Array;
12
+ /** Unit face normals, one per vertex. */
13
+ normals: Float32Array;
14
+ indices: Uint32Array;
15
+ groups: PlanMeshGroup[];
16
+ }
17
+ export interface BuildPlanMeshOptions {
18
+ /** Storey height for walls that do not carry their own. Defaults to the floor's. */
19
+ wallHeight?: number;
20
+ /** Lay a slab under each room. Default true. */
21
+ includeFloorSlab?: boolean;
22
+ /** Millimetres. The slab hangs below the floor level. Default 200. */
23
+ slabThickness?: number;
24
+ /**
25
+ * Lift everything by the floor's own elevation, so several storeys stack into one
26
+ * scene. Default true.
27
+ */
28
+ applyElevation?: boolean;
29
+ }
30
+ /**
31
+ * Triangles for one floor: every wall, with its openings cut out, and a slab per room.
32
+ *
33
+ * Call it per storey and concatenate to build a whole building; each floor already knows
34
+ * its own elevation.
35
+ */
36
+ export declare function buildPlanMesh(floor: PlanFloor, options?: BuildPlanMeshOptions): PlanMesh;
@@ -0,0 +1,11 @@
1
+ import { PlanPoint } from './floorPlan.types';
2
+ /**
3
+ * Triangulates a ring and its holes into index triples over the returned vertex list.
4
+ *
5
+ * The vertices come back as well as the indices because bridging a hole duplicates the
6
+ * two vertices it joins, so the caller cannot simply index into what it passed in.
7
+ */
8
+ export declare function triangulate(outer: readonly PlanPoint[], holes?: readonly (readonly PlanPoint[])[]): {
9
+ vertices: PlanPoint[];
10
+ indices: number[];
11
+ };
@@ -16,13 +16,15 @@ export { commitDerivation, derivePlan, pruneUnmatchedRooms, roomAtPoint } from '
16
16
  export type { CommitDerivationOptions } from './floorPlan.rooms';
17
17
  export { HIT_PX, HIT_PX_COARSE, hitTest, selectionsEqual, worldTolerances } from './floorPlan.hit';
18
18
  export type { HitTestOptions, HitTolerances } from './floorPlan.hit';
19
- export { DEFAULT_WALL_ATTRS, addOpening, addRect, addWall, cleanupPlan, dissolveNode, mergeNodes, moveNode, removeOpening, removeWall, scalePlan, splitWall, transformPlan, updateOpening, validatePlan, wallLength, } from './floorPlan.graph';
20
- export type { DissolveNodeOptions, ResolveNodeResult, SplitWallResult, WallAttrs, } from './floorPlan.graph';
19
+ export { DEFAULT_WALL_ATTRS, addOpening, addRect, addWall, cleanupPlan, detachWall, dissolveNode, mergeNodes, moveNode, removeOpening, removeWall, scalePlan, splitWall, transformPlan, updateOpening, validatePlan, wallLength, weldShortWalls, } from './floorPlan.graph';
20
+ export type { DetachWallResult, DissolveNodeOptions, ResolveNodeResult, SplitWallResult, WallAttrs, } from './floorPlan.graph';
21
21
  export { DEFAULT_SNAP_OPTIONS, SnapKind, clientToWorld, resolveSnap, screenScale } from './floorPlan.snap';
22
22
  export type { SnapContext, SnapGuide, SnapOptions, SnapResult } from './floorPlan.snap';
23
23
  export { buildWallPaths, openingGeometry, wallCentrelineSpans } from './floorPlan.path';
24
24
  export type { BuildWallPathsOptions, WallCapLine, WallCapReason, WallPathGroup, WallPathResult, } from './floorPlan.path';
25
25
  export { PLAN_FORMAT_VERSION, planFromJson, planToJson } from './floorPlan.json';
26
26
  export type { PlanParseResult } from './floorPlan.json';
27
+ export { buildPlanMesh } from './floorPlan.mesh';
28
+ export type { BuildPlanMeshOptions, PlanMesh, PlanMeshGroup, PlanMeshPart } from './floorPlan.mesh';
27
29
  export { planFromLegacySvg } from './legacySvg';
28
30
  export type { LegacyImportOptions } from './legacySvg';
@@ -0,0 +1,40 @@
1
+ import { RefObject } from 'react';
2
+ export interface UseAnchorTrackingArgs {
3
+ isOpen: boolean;
4
+ /** The element the popup is anchored to. Its scrollable ancestors are what get tracked. */
5
+ triggerRef: RefObject<HTMLElement | null>;
6
+ /**
7
+ * Every element the positioning maths needs. Tracking starts only once all of them, plus the
8
+ * trigger, are mounted. Read fresh on each setup, so an inline array literal is fine.
9
+ */
10
+ elementRefs: RefObject<HTMLElement | null>[];
11
+ /**
12
+ * Positions the popup. Called once on setup and then on every scroll, resize and tracked size
13
+ * change. Reads the caller's own refs for everything beyond the two arguments.
14
+ */
15
+ update: (trigger: HTMLElement, ancestors: HTMLElement[]) => void;
16
+ /**
17
+ * Elements whose own size changes must re-run `update`: a popup whose content changes size
18
+ * while open (a filtered list shrinking, an async list loading in) otherwise keeps coordinates
19
+ * computed for its old height and ends up detached from the trigger. Re-read on every update,
20
+ * since a panel can mount a commit later than the element it hangs off.
21
+ */
22
+ resizeRefs?: RefObject<HTMLElement | null>[];
23
+ /** Hands the popup's elements back as they were found. Runs when tracking stops. */
24
+ teardown?: () => void;
25
+ /** Values that must restart tracking, and so re-run `update`, when they change. */
26
+ deps?: readonly unknown[];
27
+ }
28
+ /**
29
+ * The lifecycle half of anchoring a portaled, `position: fixed` popup to a trigger: waits for the
30
+ * elements to mount, resolves the trigger's scrolling ancestors once, and re-runs the caller's
31
+ * positioning on every scroll, viewport resize and tracked size change until it closes.
32
+ *
33
+ * Callers supply only the geometry (`update`) and the inline styles to clear again (`teardown`);
34
+ * `useFloatingPosition` and `usePopoverPosition` are both built on this.
35
+ *
36
+ * Position is deliberately written straight to the DOM by the caller rather than through React
37
+ * state: going through setState -> re-render -> commit adds a round-trip that lags behind the
38
+ * browser's own scroll painting by at least a frame.
39
+ */
40
+ export declare function useAnchorTracking({ isOpen, triggerRef, elementRefs, update, resizeRefs, teardown, deps, }: UseAnchorTrackingArgs): void;
@@ -19,6 +19,12 @@ export interface InputDropdownProps extends Omit<HtmlProps, 'onSelect'> {
19
19
  * instead of only when an item is picked from the list.
20
20
  */
21
21
  allowCustomValue?: boolean;
22
+ /**
23
+ * Maximum number of filtered items rendered in the list. Matches beyond this
24
+ * are dropped (the user narrows by typing, not by scrolling). `0` renders all
25
+ * matches. Default `100`.
26
+ */
27
+ maxItems?: number;
22
28
  onSelect?: (value: string) => void;
23
29
  onChange?: (value: unknown) => void;
24
30
  placeholder?: string;
@@ -0,0 +1,2 @@
1
+ export { default } from './multiSelect';
2
+ export * from './multiSelect.types';
@@ -0,0 +1,4 @@
1
+ import { ReactElement } from 'react';
2
+ import { MultiSelectProps } from './multiSelect.types';
3
+ declare function MultiSelect(props: MultiSelectProps): ReactElement;
4
+ export default MultiSelect;
@@ -0,0 +1,62 @@
1
+ import { CSSProperties } from 'react';
2
+ import { FormValidator } from '../../services/formValidation';
3
+ import { SlotClassNames, SlotStyles, HtmlProps } from '../types/slots.types';
4
+ import { Presentation } from '../floatingMenu/floatingMenu.types';
5
+ export type MultiSelectValue = string | number;
6
+ export interface MultiSelectItem {
7
+ key: MultiSelectValue;
8
+ /** Visible text. A string, not a `ReactNode`, because it also renders inside a `Chip`. */
9
+ label: string;
10
+ /** Disable just this option. */
11
+ disabled?: boolean;
12
+ }
13
+ export type MultiSelectSlots = 'root' | 'label' | 'fieldset' | 'control' | 'values' | 'chip' | 'placeholder' | 'clearButton' | 'icon' | 'panel' | 'search' | 'options' | 'option' | 'optionCheck' | 'optionLabel' | 'empty';
14
+ export interface MultiSelectProps extends Omit<HtmlProps<HTMLDivElement>, 'onChange' | 'value' | 'defaultValue'> {
15
+ /** The selectable options. */
16
+ items: MultiSelectItem[];
17
+ /** Controlled selection. Omit for uncontrolled. */
18
+ value?: MultiSelectValue[];
19
+ /** Initial selection when uncontrolled (default `[]`). */
20
+ defaultValue?: MultiSelectValue[];
21
+ /** Fires with the full new selection, in the order the items were picked. */
22
+ onChange?: (value: MultiSelectValue[]) => void;
23
+ /** Field label. Rests inside the control and floats up into the border notch, like `Input`. */
24
+ label?: string;
25
+ /** Keep the label floating even when the field is empty and unfocused (default `false`). */
26
+ alwaysFloatLabel?: boolean;
27
+ /** Shown while nothing is selected. With a `label`, only once the label has floated clear. */
28
+ placeholder?: string;
29
+ /** Show a search box in the panel that filters the options (default `false`). */
30
+ searchable?: boolean;
31
+ /**
32
+ * Let the user add a value that isn't in `items` by typing it and picking the "add" row that
33
+ * appears. Implies `searchable`, since the search box is where the value is typed. The new
34
+ * value becomes its own key, so `onChange` reports it as a plain string.
35
+ */
36
+ allowCustomValues?: boolean;
37
+ /** Label for the row that adds a typed value (default `Add "…"`). */
38
+ createLabel?: (value: string) => string;
39
+ /** Placeholder for the search box (default `'Search…'`). */
40
+ searchPlaceholder?: string;
41
+ /** Text shown in the panel when no option matches (default `'No matches'`). */
42
+ emptyLabel?: string;
43
+ /** Cap the number of selections. Unselected options disable once the cap is reached. */
44
+ maxSelected?: number;
45
+ /** Show a button that clears the whole selection (default `true`). */
46
+ clearable?: boolean;
47
+ disabled?: boolean;
48
+ /** Show the selection but allow no changes, without the dimming `disabled` applies. */
49
+ readOnly?: boolean;
50
+ /** Where the panel opens: anchored to the control, as a bottom sheet, or a sheet only on a
51
+ * small touch screen. Forwarded to `Popover`. */
52
+ presentation?: Presentation;
53
+ formValidator?: FormValidator | null;
54
+ /** Manual error message shown in a tooltip (used when there's no `formValidator`). */
55
+ errorMessage?: string;
56
+ /** Manual valid state when not using a `formValidator` (default `true`). */
57
+ isValid?: boolean;
58
+ className?: string;
59
+ style?: CSSProperties;
60
+ classNames?: SlotClassNames<MultiSelectSlots>;
61
+ styles?: SlotStyles<MultiSelectSlots>;
62
+ }
@@ -0,0 +1,2 @@
1
+ export { default } from './pagination';
2
+ export * from './pagination.types';
@@ -0,0 +1,4 @@
1
+ import { ReactElement } from 'react';
2
+ import { PaginationProps } from './pagination.types';
3
+ declare function Pagination(props: PaginationProps): ReactElement | null;
4
+ export default Pagination;
@@ -0,0 +1,30 @@
1
+ import { CSSProperties } from 'react';
2
+ import { IconContent } from '../types/icon.types';
3
+ import { SlotClassNames, SlotStyles, HtmlProps } from '../types/slots.types';
4
+ export type PaginationSlots = 'root' | 'item' | 'control' | 'ellipsis';
5
+ export interface PaginationProps extends Omit<HtmlProps<HTMLElement>, 'onChange'> {
6
+ /** Total number of pages. Nothing renders below `1`. */
7
+ total: number;
8
+ /** Controlled current page, 1-based (omit for uncontrolled). */
9
+ page?: number;
10
+ /** Initial page when uncontrolled (default `1`). */
11
+ defaultPage?: number;
12
+ onChange?: (page: number) => void;
13
+ /** Pages shown either side of the current one (default `1`). */
14
+ siblings?: number;
15
+ /** Pages always shown at the start and end (default `1`). */
16
+ boundaries?: number;
17
+ /** Show the previous/next controls (default `true`). */
18
+ showControls?: boolean;
19
+ /** Show first/last jump controls outside the previous/next ones (default `false`). */
20
+ showEdges?: boolean;
21
+ disabled?: boolean;
22
+ previousIcon?: IconContent;
23
+ nextIcon?: IconContent;
24
+ firstIcon?: IconContent;
25
+ lastIcon?: IconContent;
26
+ className?: string;
27
+ style?: CSSProperties;
28
+ classNames?: SlotClassNames<PaginationSlots>;
29
+ styles?: SlotStyles<PaginationSlots>;
30
+ }
@@ -17,7 +17,9 @@ export interface UsePopoverPositionArgs {
17
17
  * Positions a portaled, `position: fixed` popover on any of the four sides of its trigger with
18
18
  * start/center/end cross-axis alignment. Flips to the opposite side when the preferred one has no
19
19
  * room, shifts back on-screen along the cross axis, points the arrow at the trigger centre, and
20
- * hides (not closes) the panel once the trigger scrolls behind a clipping ancestor. Position is
21
- * written straight to the DOM via refs — going through setState lags the browser's scroll paint.
20
+ * hides (not closes) the panel once the trigger scrolls behind a clipping ancestor.
21
+ *
22
+ * `useAnchorTracking` supplies the lifecycle around this: mount wait, ancestor resolution, and
23
+ * re-running on scroll, viewport resize and panel size changes.
22
24
  */
23
25
  export declare function usePopoverPosition({ isOpen, placement, align, offset, triggerRef, floatingRef, panelRef, arrowRef, }: UsePopoverPositionArgs): void;
@@ -0,0 +1,3 @@
1
+ import { FlatTreeNode, TreeNode, TreeValue } from './tree.types';
2
+ /** The visible nodes in render order. Flat, not nested, deliberately: see docs/Tree.md. */
3
+ export declare function flattenTree(nodes: TreeNode[], expandedKeys: TreeValue[], level?: number, parentKey?: TreeValue | null): FlatTreeNode[];
@@ -0,0 +1,3 @@
1
+ export { default } from './tree';
2
+ export { flattenTree } from './flattenTree';
3
+ export * from './tree.types';
@@ -0,0 +1,4 @@
1
+ import { ReactElement } from 'react';
2
+ import { TreeProps } from './tree.types';
3
+ declare function Tree(props: TreeProps): ReactElement;
4
+ export default Tree;
@@ -0,0 +1,62 @@
1
+ import { CSSProperties, ReactNode } from 'react';
2
+ import { IconContent } from '../types/icon.types';
3
+ import { SlotClassNames, SlotStyles, HtmlProps } from '../types/slots.types';
4
+ export type TreeValue = string | number;
5
+ export interface TreeNode {
6
+ key: TreeValue;
7
+ label: ReactNode;
8
+ /** Icon shown before the label. */
9
+ icon?: IconContent;
10
+ /** Child nodes. An empty array still renders a toggle, for a branch whose children load later. */
11
+ children?: TreeNode[];
12
+ disabled?: boolean;
13
+ }
14
+ /** One visible row produced by `flattenTree`. */
15
+ export interface FlatTreeNode {
16
+ node: TreeNode;
17
+ /** Depth from the roots, 0-based. */
18
+ level: number;
19
+ parentKey: TreeValue | null;
20
+ isBranch: boolean;
21
+ isExpanded: boolean;
22
+ /** Number of siblings at this level, for `aria-setsize`. */
23
+ setSize: number;
24
+ /** 1-based position among those siblings, for `aria-posinset`. */
25
+ posInSet: number;
26
+ }
27
+ export type TreeSlots = 'root' | 'node' | 'toggle' | 'icon' | 'label' | 'empty';
28
+ export interface TreeProps extends Omit<HtmlProps<HTMLDivElement>, 'onSelect'> {
29
+ nodes: TreeNode[];
30
+ /** Controlled set of expanded branch keys. Omit for uncontrolled. */
31
+ expandedKeys?: TreeValue[];
32
+ /** Branches expanded initially when uncontrolled (default `[]`). */
33
+ defaultExpandedKeys?: TreeValue[];
34
+ onExpandedChange?: (keys: TreeValue[]) => void;
35
+ /** Controlled selected key. Omit for uncontrolled. */
36
+ selectedKey?: TreeValue | null;
37
+ /** Key selected initially when uncontrolled. */
38
+ defaultSelectedKey?: TreeValue;
39
+ onSelect?: (key: TreeValue, node: TreeNode) => void;
40
+ /** Clicking a branch's label toggles it as well as selecting it (default `true`). */
41
+ expandOnSelect?: boolean;
42
+ /** Indent per level in px (default `20`). */
43
+ indent?: number;
44
+ /** Icon for the expand/collapse toggle. Rotates 90° when open (default a right chevron). */
45
+ toggleIcon?: IconContent;
46
+ /**
47
+ * Sets a fixed viewport height and windows the rows through `VirtualList`, so only the visible
48
+ * ones are in the DOM. Use it past a few hundred visible nodes. Rows must then be uniform
49
+ * height: set `--tree-node-height` rather than sizing individual rows.
50
+ */
51
+ height?: number | string;
52
+ /** Row height used by the windowed renderer before measurement (default `32`). */
53
+ estimatedRowHeight?: number;
54
+ /** Shown when `nodes` is empty (default `'Nothing here'`). */
55
+ emptyLabel?: string;
56
+ disabled?: boolean;
57
+ 'aria-label'?: string;
58
+ className?: string;
59
+ style?: CSSProperties;
60
+ classNames?: SlotClassNames<TreeSlots>;
61
+ styles?: SlotStyles<TreeSlots>;
62
+ }
@@ -83,6 +83,14 @@ export interface VirtualListProps<T> extends HtmlProps {
83
83
  renderRow?: (item: T, index: number) => ReactNode;
84
84
  /** Column definitions — presence enables table/header mode. */
85
85
  columns?: VirtualListColumn<T>[];
86
+ /**
87
+ * Replaces the roles put on the container and rows (default `list`/`listitem`, or `grid`/`row`
88
+ * with columns). Pass `'none'` for both when the rows carry their own semantics, as `Tree` does.
89
+ */
90
+ ariaRoles?: {
91
+ container: string;
92
+ row: string;
93
+ };
86
94
  /** Height of the scroll viewport. Defaults to '100%' to fill the parent container. */
87
95
  height?: number | string;
88
96
  /** Estimated row height before measurement. Default: 40. */
@@ -14,7 +14,7 @@ export interface VirtualRowProps {
14
14
  ariaRowIndex?: number;
15
15
  ariaPosInSet?: number;
16
16
  ariaSetSize?: number;
17
- rowRole: 'row' | 'listitem';
17
+ rowRole: string;
18
18
  reorderable?: boolean;
19
19
  /**
20
20
  * Native mouse DnD is active for the list. Distinct from `draggable`, which in
@@ -75,6 +75,8 @@ export * from './common/klipyPicker';
75
75
  export * from './common/loading';
76
76
  export { default as Modal } from './common/modal';
77
77
  export * from './common/modal';
78
+ export { default as MultiSelect } from './common/multiSelect';
79
+ export * from './common/multiSelect';
78
80
  export { default as NumberInput } from './common/numberInput';
79
81
  export * from './common/numberInput';
80
82
  export { default as OptionPicker } from './common/optionPicker';
@@ -83,6 +85,8 @@ export { default as OtpInput } from './common/otpInput';
83
85
  export * from './common/otpInput';
84
86
  export { default as Overscroll } from './common/overscroll';
85
87
  export * from './common/overscroll';
88
+ export { default as Pagination } from './common/pagination';
89
+ export * from './common/pagination';
86
90
  export { default as Popover } from './common/popover';
87
91
  export * from './common/popover';
88
92
  export { default as ProgressBar } from './common/progressBar';
@@ -138,6 +142,8 @@ export { default as ToastProvider } from './common/toast';
138
142
  export * from './common/toast';
139
143
  export { default as Tooltip } from './common/tooltip';
140
144
  export * from './common/tooltip';
145
+ export { default as Tree } from './common/tree';
146
+ export * from './common/tree';
141
147
  export { default as VirtualList } from './common/virtualList';
142
148
  export * from './common/virtualList';
143
149
  export { default as Wizard } from './common/wizard';
package/docs/CLAUDE.md CHANGED
@@ -156,10 +156,12 @@ FontAwesome internals, so they still take an `IconDefinition` only.
156
156
  @KlipyPicker.md
157
157
  @Loading.md
158
158
  @Modal.md
159
+ @MultiSelect.md
159
160
  @NumberInput.md
160
161
  @OptionPicker.md
161
162
  @OtpInput.md
162
163
  @Overscroll.md
164
+ @Pagination.md
163
165
  @Popover.md
164
166
  @ProgressBar.md
165
167
  @RadioGroup.md
@@ -186,5 +188,6 @@ FontAwesome internals, so they still take an `IconDefinition` only.
186
188
  @Timer.md
187
189
  @Toast.md
188
190
  @Tooltip.md
191
+ @Tree.md
189
192
  @VirtualList.md
190
193
  @Wizard.md
package/docs/FloorPlan.md CHANGED
@@ -52,6 +52,8 @@ Every operation is pure and returns `PlanEdit<T>` = `{ floor, result, issues }`,
52
52
  | `splitWall(floor, wallId, at)` | Cuts a wall at a point or 0..1 parameter; redistributes its openings |
53
53
  | `mergeNodes(floor, keepId, removeId)` | Welds two nodes, deduplicating the walls that collapse together |
54
54
  | `dissolveNode(floor, nodeId)` | Rejoins two collinear same-construction walls; the inverse of `splitWall` |
55
+ | `detachWall(floor, wallId, delta)` | Pulls a wall off its shared corners and moves it by `delta`, bridging each old corner to the new one |
56
+ | `weldShortWalls(floor)` | Collapses every wall shorter than `MIN_WALL_LENGTH` by merging its two ends, so a squashed wall never leaves two corners stacked on one point |
55
57
  | `removeWall(floor, wallId, opts?)` | Removes a wall, its openings, and any node left orphaned |
56
58
  | `moveNode` / `transformPlan` / `scalePlan` | Move one node, translate everything, or rescale everything |
57
59
  | `addOpening` / `updateOpening` / `removeOpening` | Openings, clamped to fit their wall |
@@ -207,7 +209,43 @@ Feeding every legacy polygon edge through `addWall` is what collapses the old du
207
209
 
208
210
  ## Ready for 3D
209
211
 
210
- The model carries everything an extrusion needs and nothing it would have to invent: `PlanWall.thickness` / `height` / `baseHeight`, `PlanOpening.sill` / `head`, `PlanFloor.elevation` / `height`, and derived floor polygons. No 3D renderer ships today.
212
+ `buildPlanMesh(floor, options?)` turns a storey into triangles: wall solids with their openings cut out, and a slab under each room.
213
+
214
+ ```ts
215
+ import { buildPlanMesh } from '@ahrowe/ui';
216
+
217
+ const mesh = buildPlanMesh(plan.floors[0]);
218
+ // { positions: Float32Array, normals: Float32Array, indices: Uint32Array, groups }
219
+
220
+ // three.js, for example — no adapter needed
221
+ const g = new THREE.BufferGeometry();
222
+ g.setAttribute('position', new THREE.BufferAttribute(mesh.positions, 3));
223
+ g.setAttribute('normal', new THREE.BufferAttribute(mesh.normals, 3));
224
+ g.setIndex(new THREE.BufferAttribute(mesh.indices, 1));
225
+ ```
226
+
227
+ There is no renderer and no dependency: plain arrays are what every engine takes, so this feeds three.js, Babylon, a glTF writer or your own rasteriser without the package having an opinion. Call it per storey and concatenate for a whole building; each floor already knows its own `elevation`.
228
+
229
+ **Axes.** Plan x becomes world x, plan y becomes world **z**, and world y is up. A plan is drawn in SVG's y-down space, so that mapping is a handedness flip — faces are wound here to suit, rather than inheriting the 2D rings' winding.
230
+
231
+ **Openings are cut by splitting the wall, not by subtracting solids.** Every opening is an axis-aligned rectangle in its wall's own plane, so the wall falls into at most three prisms: under the sill, over the head, and the full-height stretches between openings. Exact, and no CSG library. A window leaves its upstand and lintel behind, which is what `sill` and `head` are carried for.
232
+
233
+ **Storeys stack wall to wall.** `elevation` is the *finished floor level*, so a slab hangs below it and the walls start at the slab's underside — they clothe the floor plate the way a facade does, rather than stopping at it. A wall stays exactly `floor.height` tall, one storey's walls end precisely where the next storey's begin, and the slab sits inside them instead of showing as a band between the storeys. Build the walls from the level itself and their top face ends up coplanar with the slab above, which z-fights.
234
+
235
+ A wall also drops a millimetre past the slab rather than landing flush on its underside. That is not arbitrary: a wall overlaps the slab it stands over, because the slab reaches the wall centrelines and the wall straddles them, so sharing a plane would leave two downward faces fighting for the same pixels and speckle the whole underside of the building. Wall heights and floor-to-floor spacing are unaffected.
236
+
237
+ `groups` marks which triangles are wall and which are floor, so the two can take different materials in one draw.
238
+
239
+ | Option | Default | |
240
+ |---|---|---|
241
+ | `wallHeight` | the floor's `height` | For walls with no `height` of their own |
242
+ | `includeFloorSlab` | `true` | Lay a slab under each room |
243
+ | `slabThickness` | `200` | Millimetres, hanging below floor level |
244
+ | `applyElevation` | `true` | Lift by the floor's own elevation, so storeys stack |
245
+
246
+ **Corners are mitred.** Two walls meeting at a corner both stop at the shared node, so left square they leave a gap of half a thickness on the outside of the turn — a notch cut through the full height of the wall. Their footprints are extended to where their edges actually cross instead, which is the same join the 2D renderer makes.
247
+
248
+ An end stays square where there is no single corner to mitre to: a junction of three or more walls, a change of thickness across the node, an angle acute enough to send the mitre running away, and the reveals of an opening, which are square faces in their own right. Walls overlap slightly at those joints, which is invisible on an opaque solid but would show in a cutaway or through glass, and would double-count in a volume taken straight off the triangles.
211
249
 
212
250
  These are the fields that do nothing in 2D, and that is expected rather than a gap: a plan is a horizontal cut, so a window 900mm up the wall and a door at floor level are the same hole seen from above. `sill` is the height of an opening's bottom above the floor and `head` the height of its top; the drawer edits `sill` in its selection bar so the value is captured while the plan is being drawn, rather than having to be filled in for every opening afterwards.
213
251
 
@@ -58,6 +58,7 @@ interface InputDropdownItem {
58
58
  | `value` | `string` | Controlled value |
59
59
  | `items` | `InputDropdownItem[]` | Dropdown options |
60
60
  | `allowCustomValue` | `boolean` | Allow free-text values not in `items` — the typed text is emitted via `onChange` on every keystroke (default `false`) |
61
+ | `maxItems` | `number` | Maximum number of filtered matches rendered in the list. Further matches are dropped, since the list is narrowed by typing rather than by scrolling. `0` renders all matches. Default `100` |
61
62
  | `onSelect` | `(value: string) => void` | Called when an item is picked from the list |
62
63
  | `onChange` | `(value: unknown) => void` | Called on every text change |
63
64
  | `placeholder` | `string` | |
@@ -68,4 +69,6 @@ interface InputDropdownItem {
68
69
 
69
70
  **Slots:** `root` `dropdown` `item`
70
71
 
72
+ Long lists are handled by capping how many matches are rendered (`maxItems`, default `100`) rather than by virtualizing: only about six rows fit in the scroll box, so anything past the cap is DOM the user never sees. Raise `maxItems` (or set it to `0`) if a list is genuinely meant to be scrolled end to end. For lists too large to pass as `items` at all, fetch on the server, keep the results in your own state and feed them in as `items`.
73
+
71
74
  The list is rendered through a portal and tracks the trigger across scroll, so it always escapes clipping ancestors (cards, scroll containers, virtualized lists). It flips to open upward automatically when there's no room below, and shifts horizontally to stay within the viewport when the trigger sits near the left or right edge of the screen. It hides (without closing) if the trigger itself scrolls behind a clipping ancestor, reappearing once it's back in view. It also re-measures when its own content changes size while open, so a list that shrinks (e.g. filtered down to fewer entries) stays anchored to the trigger instead of hanging above it.