@lotics/ui 20.0.0 → 20.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +39 -0
- package/docs/data_entry.md +5 -0
- package/package.json +5 -3
- package/src/chip_group.tsx +8 -1
- package/src/control_surface.ts +7 -0
- package/src/detail_row.tsx +2 -5
- package/src/popover.tsx +19 -1
- package/src/agent_transform.test.ts +0 -96
- package/src/calendar/layout.test.ts +0 -103
- package/src/colors.test.ts +0 -45
- package/src/date_filter_presets.test.ts +0 -62
- package/src/date_picker_value.test.ts +0 -204
- package/src/date_range_selection.test.ts +0 -60
- package/src/date_segments.test.ts +0 -335
- package/src/file_badge_fit.test.ts +0 -49
- package/src/file_intake.test.ts +0 -124
- package/src/format_date.test.ts +0 -138
- package/src/format_date_negative_zone.test.ts +0 -26
- package/src/gantt/scale.test.ts +0 -47
- package/src/inline_focus.test.ts +0 -49
- package/src/matrix.test.ts +0 -54
- package/src/pending_commits.test.ts +0 -68
- package/src/popover_layers.test.ts +0 -113
- package/src/primitives_purity.test.ts +0 -73
- package/src/remark_gfm_safe.test.ts +0 -33
- package/src/share_or_download.test.ts +0 -92
- package/src/table.test.ts +0 -90
- package/src/task_metrics.test.ts +0 -48
- package/src/use_list_keyboard_nav.test.ts +0 -184
- package/src/use_option_list.test.ts +0 -232
- package/src/use_section_nav.test.ts +0 -171
- package/src/use_selection.test.ts +0 -66
- package/src/vite.test.ts +0 -41
package/LICENSE.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Copyright (c) 2026 Lotics Technology Company Limited. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software is proprietary. It is published to the public npm registry for one
|
|
4
|
+
purpose: building on the Lotics platform.
|
|
5
|
+
|
|
6
|
+
In this license, **Lotics** means Lotics Technology Company Limited, and **the
|
|
7
|
+
Lotics platform** means the hosted services Lotics operates at lotics.ai and its
|
|
8
|
+
subdomains, together with applications, packages and integrations that run on or
|
|
9
|
+
communicate with those services.
|
|
10
|
+
|
|
11
|
+
## Permitted
|
|
12
|
+
|
|
13
|
+
You may install this package and use it **solely in connection with the Lotics
|
|
14
|
+
platform** — including bundling it, in whole or in part, into an application that
|
|
15
|
+
runs on or integrates with the Lotics platform.
|
|
16
|
+
|
|
17
|
+
## Not permitted
|
|
18
|
+
|
|
19
|
+
Without prior written permission from Lotics you may not:
|
|
20
|
+
|
|
21
|
+
1. use this software, or any portion of it, independently of the Lotics platform
|
|
22
|
+
— including in any application, product or service that neither runs on nor
|
|
23
|
+
integrates with the Lotics platform;
|
|
24
|
+
2. copy, republish, redistribute or sublicense the source code, in whole or in
|
|
25
|
+
part, other than as the bundled dependency described above;
|
|
26
|
+
3. create derivative works from the source code, or incorporate any portion of it
|
|
27
|
+
into another library, product or package;
|
|
28
|
+
4. reverse engineer, decompile or disassemble any compiled artifact, except to the
|
|
29
|
+
extent that restriction is unenforceable under applicable law;
|
|
30
|
+
5. use the source code, or any portion of it, to train or fine-tune a machine
|
|
31
|
+
learning model.
|
|
32
|
+
|
|
33
|
+
## No warranty
|
|
34
|
+
|
|
35
|
+
This software is provided "as is", without warranty of any kind, express or
|
|
36
|
+
implied. In no event shall Lotics be liable for any claim, damages or other
|
|
37
|
+
liability arising from the software or its use.
|
|
38
|
+
|
|
39
|
+
For any other use, contact minh@lotics.ai.
|
package/docs/data_entry.md
CHANGED
|
@@ -115,6 +115,11 @@ One per type:
|
|
|
115
115
|
GROWS with its content; only a plain label row is the fixed-height `MenuButton`. So a short
|
|
116
116
|
code + the full official name underneath is a supported option shape, not a hack. Reach for it
|
|
117
117
|
whenever the real name is too long to READ in a list but must stay exact somewhere.
|
|
118
|
+
**The popover is the FIELD's width** (floored at `MIN_CONTROL_WIDTH` so a dense grid cell still
|
|
119
|
+
opens something readable) — shared by `Select` and `Combobox`, every popover anchored to a
|
|
120
|
+
field. So option text longer than the field WRAPS; it never widens the panel, and a paragraph-
|
|
121
|
+
length option cannot open a list wider than the drawer or column the field lives in. Give a
|
|
122
|
+
long description line a `numberOfLines` clamp so rows stay even and the list stays scannable.
|
|
118
123
|
- **`InlineMemberSelect`** — a `MemberChip` at rest → member picker; the inline twin of
|
|
119
124
|
`MemberSelect`; worked example: `tpl_record`'s "Sales owner" fact.
|
|
120
125
|
- **`InlineDatePicker`** — `format="datetime"` for always-on time; `optionalTime` to let the user
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "20.0.
|
|
3
|
+
"version": "20.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -248,7 +248,9 @@
|
|
|
248
248
|
"examples",
|
|
249
249
|
"AGENTS.md",
|
|
250
250
|
"MIGRATION.md",
|
|
251
|
-
"docs"
|
|
251
|
+
"docs",
|
|
252
|
+
"!src/**/*.test.ts",
|
|
253
|
+
"!src/**/*.test.tsx"
|
|
252
254
|
],
|
|
253
255
|
"publishConfig": {
|
|
254
256
|
"access": "public"
|
|
@@ -258,7 +260,7 @@
|
|
|
258
260
|
"url": "https://github.com/lotics/lotics.git",
|
|
259
261
|
"directory": "packages/ui"
|
|
260
262
|
},
|
|
261
|
-
"license": "
|
|
263
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
262
264
|
"dependencies": {
|
|
263
265
|
"@lotics/docx": "^0.1.0",
|
|
264
266
|
"@lotics/xlsx": "^0.1.0",
|
package/src/chip_group.tsx
CHANGED
|
@@ -51,7 +51,14 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
|
|
|
51
51
|
onPress={() => onValueChange(option.value)}
|
|
52
52
|
accessibilityRole="button"
|
|
53
53
|
accessibilityLabel={`${accessibilityLabel}: ${option.label}`}
|
|
54
|
-
|
|
54
|
+
// A TOGGLE BUTTON, so which chip is active is announced. It has to
|
|
55
|
+
// be a raw `aria-pressed`, not `accessibilityState`: react-native-web
|
|
56
|
+
// maps only the states with a native counterpart, and it drops
|
|
57
|
+
// `selected` on a `button` role — which left the active chip
|
|
58
|
+
// distinguished by nothing but a 1px ring, invisible to a screen
|
|
59
|
+
// reader (`RadioPicker` and `SegmentedControl` pass raw `aria-checked`
|
|
60
|
+
// for the same reason).
|
|
61
|
+
aria-pressed={active}
|
|
55
62
|
// The shared pill-surface contract (height/border/radius/white +
|
|
56
63
|
// hover/press wash + the selected box-shadow ring); the chip only
|
|
57
64
|
// adds its horizontal padding.
|
package/src/control_surface.ts
CHANGED
|
@@ -5,6 +5,13 @@ import { colors } from "./colors";
|
|
|
5
5
|
* buttons) aligns to it so a toolbar row reads as one band. */
|
|
6
6
|
export const CONTROL_HEIGHT = 40;
|
|
7
7
|
|
|
8
|
+
/** The narrowest a control may be and still be usable — below it, editors get
|
|
9
|
+
* crushed and option text has nowhere to go. It is the threshold at which a
|
|
10
|
+
* `DetailTable` gives up its side-by-side columns and stacks, and the floor
|
|
11
|
+
* under a field popover's width, so "too narrow to use" means one thing across
|
|
12
|
+
* the kit rather than a number re-picked per component. */
|
|
13
|
+
export const MIN_CONTROL_WIDTH = 160;
|
|
14
|
+
|
|
8
15
|
/** The system control radius — every interactive control surface (Button, the
|
|
9
16
|
* inputs/selects/pickers, SearchInput, MenuButton, Tabs, the SegmentedControl
|
|
10
17
|
* track, and ChipGroup/FilterChip/Chip via the surfaces below) wears it,
|
package/src/detail_row.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createContext, ReactNode, useContext, useState } from "react";
|
|
2
2
|
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
|
+
import { MIN_CONTROL_WIDTH } from "./control_surface";
|
|
3
4
|
import { FieldAnnotationProps, FieldAnnotations, hasFieldAnnotation } from "./field_annotations";
|
|
4
5
|
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
5
6
|
import { getInputLineHeight } from "./text_utils";
|
|
@@ -16,10 +17,6 @@ interface DetailTableColumns {
|
|
|
16
17
|
|
|
17
18
|
const DetailTableContext = createContext<DetailTableColumns | null>(null);
|
|
18
19
|
|
|
19
|
-
/** The value cell must keep at least this width before the table gives up its
|
|
20
|
-
* side-by-side columns and stacks — below it, editors get crushed. */
|
|
21
|
-
const MIN_VALUE_WIDTH = 160;
|
|
22
|
-
|
|
23
20
|
/** THE kit's label column — ~18 characters, where real field names live. It is
|
|
24
21
|
* this table's default AND a `TaskList`'s, so a record's fields and a task's
|
|
25
22
|
* read as one language on a surface carrying both; that only holds while the
|
|
@@ -73,7 +70,7 @@ export interface DetailTableProps {
|
|
|
73
70
|
* the switch is automatic.
|
|
74
71
|
*/
|
|
75
72
|
export function DetailTable(props: DetailTableProps) {
|
|
76
|
-
const { labelWidth = DETAIL_LABEL_WIDTH, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth =
|
|
73
|
+
const { labelWidth = DETAIL_LABEL_WIDTH, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_CONTROL_WIDTH, children, style } = props;
|
|
77
74
|
const [width, setWidth] = useState<number | null>(null);
|
|
78
75
|
const stacked =
|
|
79
76
|
width != null && width < labelWidth + (trailingWidth ?? 0) + minValueWidth + 24;
|
package/src/popover.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import React, {
|
|
|
9
9
|
} from "react";
|
|
10
10
|
import { ScrollView, View, ViewStyle, StyleProp } from "react-native";
|
|
11
11
|
import { colors } from "./colors";
|
|
12
|
+
import { MIN_CONTROL_WIDTH } from "./control_surface";
|
|
12
13
|
import { IconButton } from "./icon_button";
|
|
13
14
|
import { Portal } from "./portal";
|
|
14
15
|
import { Divider } from "./divider";
|
|
@@ -641,10 +642,27 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
641
642
|
display: "flex",
|
|
642
643
|
flexDirection: "column",
|
|
643
644
|
}),
|
|
645
|
+
// INHERIT means inherit — the width, not just a floor under it. The
|
|
646
|
+
// flag is set only by a popover anchored to a FIELD (Select,
|
|
647
|
+
// InlineSelect, Combobox), and a field's own width is the right width
|
|
648
|
+
// for the list of values that field can hold: option text longer than
|
|
649
|
+
// the field belongs on a second line, not on a wider panel.
|
|
650
|
+
//
|
|
651
|
+
// As a floor alone this LOOKED right, because with short options
|
|
652
|
+
// max-content lands on the trigger width anyway. It broke the moment
|
|
653
|
+
// an option was a sentence: the panel grew to the `maxWidth: 800` cap
|
|
654
|
+
// and then slid sideways to stay on screen, so a 371px field opened an
|
|
655
|
+
// 800px list starting 421px to its left, outside the drawer that owns
|
|
656
|
+
// the field.
|
|
657
|
+
//
|
|
658
|
+
// Floored at MIN_CONTROL_WIDTH so a genuinely narrow trigger — a
|
|
659
|
+
// `variant="cell"` select in a dense grid column — still opens a list
|
|
660
|
+
// wide enough to read, rather than inheriting a width nothing fits in.
|
|
644
661
|
...(inheritTriggerWidth &&
|
|
645
662
|
!small &&
|
|
646
663
|
triggerWidth > 0 && {
|
|
647
|
-
|
|
664
|
+
width: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
|
|
665
|
+
maxWidth: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
|
|
648
666
|
}),
|
|
649
667
|
}}
|
|
650
668
|
onClick={(e) => e.stopPropagation()}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { toSegments, anyRunning, lastRunningStep, type AgentUIPart, type AgentSegment, type AgentStep } from "./agent_transform";
|
|
3
|
-
|
|
4
|
-
// RN-free logic test for the parts → timeline fold. The renderer (agent_run.tsx)
|
|
5
|
-
// is verified visually in the gallery; the mapping — ai's 7-state tool machine to
|
|
6
|
-
// the feed's 4 states, plus grouping — is unit-tested here. Fixtures are the ai-sdk
|
|
7
|
-
// `dynamic-tool` part shape (contextually typed as `AgentUIPart`, so each concrete
|
|
8
|
-
// `state` discriminates to the right member — no casts).
|
|
9
|
-
|
|
10
|
-
function groups(parts: AgentUIPart[]): Extract<AgentSegment, { kind: "group" }>[] {
|
|
11
|
-
return toSegments(parts).filter((s): s is Extract<AgentSegment, { kind: "group" }> => s.kind === "group");
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function onlyStep(parts: AgentUIPart[]): AgentStep {
|
|
15
|
-
const [group, ...rest] = groups(parts);
|
|
16
|
-
expect(rest).toHaveLength(0);
|
|
17
|
-
expect(group.steps).toHaveLength(1);
|
|
18
|
-
return group.steps[0];
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
describe("agent_transform — tool state mapping", () => {
|
|
22
|
-
it("input-streaming → running", () => {
|
|
23
|
-
expect(onlyStep([{ type: "dynamic-tool", toolName: "update_records", toolCallId: "t", state: "input-streaming", input: {} }]).status).toBe("running");
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("input-available → running", () => {
|
|
27
|
-
expect(onlyStep([{ type: "dynamic-tool", toolName: "update_records", toolCallId: "t", state: "input-available", input: {} }]).status).toBe("running");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("approval-requested → awaiting (parked on a human decision)", () => {
|
|
31
|
-
const step = onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "approval-requested", input: { table: "Containers" }, approval: { id: "a1" } }]);
|
|
32
|
-
expect(step.status).toBe("awaiting");
|
|
33
|
-
// The input still rides along for the on-demand peek.
|
|
34
|
-
expect(step.input).toEqual({ table: "Containers" });
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("approval-responded → running (the decision is in, work resumes)", () => {
|
|
38
|
-
expect(onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "approval-responded", input: {}, approval: { id: "a1", approved: true } }]).status).toBe("running");
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("output-available → done, carrying the output", () => {
|
|
42
|
-
const step = onlyStep([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "output-available", input: { q: 1 }, output: { rows: 3 } }]);
|
|
43
|
-
expect(step.status).toBe("done");
|
|
44
|
-
expect(step.output).toEqual({ rows: 3 });
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("output-error → error, carrying errorText", () => {
|
|
48
|
-
const step = onlyStep([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "output-error", input: {}, errorText: "Unknown field" }]);
|
|
49
|
-
expect(step.status).toBe("error");
|
|
50
|
-
expect(step.errorText).toBe("Unknown field");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("output-denied → error, with the denial reason as the error text", () => {
|
|
54
|
-
const step = onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "output-denied", input: {}, approval: { id: "a1", approved: false, reason: "Delete not permitted" } }]);
|
|
55
|
-
expect(step.status).toBe("error");
|
|
56
|
-
expect(step.errorText).toBe("Delete not permitted");
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("output-denied without a reason → error with no error text", () => {
|
|
60
|
-
const step = onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "output-denied", input: {}, approval: { id: "a1", approved: false } }]);
|
|
61
|
-
expect(step.status).toBe("error");
|
|
62
|
-
expect(step.errorText).toBeUndefined();
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe("agent_transform — streaming signals", () => {
|
|
67
|
-
it("anyRunning is true while a call runs, false once every call has settled", () => {
|
|
68
|
-
const running = toSegments([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "input-available", input: {} }]);
|
|
69
|
-
expect(anyRunning(running)).toBe(true);
|
|
70
|
-
|
|
71
|
-
const settled = toSegments([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "output-available", input: {}, output: {} }]);
|
|
72
|
-
expect(anyRunning(settled)).toBe(false);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("an awaiting call is NOT running — it's parked, so the feed never pulses it", () => {
|
|
76
|
-
const parked = toSegments([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "approval-requested", input: {}, approval: { id: "a1" } }]);
|
|
77
|
-
expect(anyRunning(parked)).toBe(false);
|
|
78
|
-
expect(lastRunningStep(parked)).toBeUndefined();
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe("agent_transform — grouping", () => {
|
|
83
|
-
it("folds consecutive tool parts into ONE group, split by prose", () => {
|
|
84
|
-
const parts: AgentUIPart[] = [
|
|
85
|
-
{ type: "text", text: "First" },
|
|
86
|
-
{ type: "dynamic-tool", toolName: "query_records", toolCallId: "a", state: "output-available", input: {}, output: {} },
|
|
87
|
-
{ type: "dynamic-tool", toolName: "query_records", toolCallId: "b", state: "output-available", input: {}, output: {} },
|
|
88
|
-
{ type: "text", text: "Then" },
|
|
89
|
-
{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "c", state: "approval-requested", input: {}, approval: { id: "a1" } },
|
|
90
|
-
];
|
|
91
|
-
expect(toSegments(parts).map((s) => s.kind)).toEqual(["text", "group", "text", "group"]);
|
|
92
|
-
const [first, second] = groups(parts);
|
|
93
|
-
expect(first.steps.map((s) => s.id)).toEqual(["a", "b"]);
|
|
94
|
-
expect(second.steps.map((s) => s.status)).toEqual(["awaiting"]);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { layoutDayColumns, packEventLanes } from "./layout";
|
|
3
|
-
import type { CalendarEvent } from "./types";
|
|
4
|
-
|
|
5
|
-
const DAY = new Date(2026, 0, 15);
|
|
6
|
-
const at = (h: number, m = 0) => new Date(2026, 0, 15, h, m);
|
|
7
|
-
const ev = (id: string, sh: number, sm: number, eh: number, em: number): CalendarEvent => ({
|
|
8
|
-
id,
|
|
9
|
-
title: id,
|
|
10
|
-
start: at(sh, sm),
|
|
11
|
-
end: at(eh, em),
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
/** column/columns keyed by event id, for order-independent assertions. */
|
|
15
|
-
function geom(events: CalendarEvent[]) {
|
|
16
|
-
return Object.fromEntries(
|
|
17
|
-
layoutDayColumns(DAY, events).map((c) => [c.event.id, { column: c.column, columns: c.columns }]),
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
describe("layoutDayColumns", () => {
|
|
22
|
-
it("returns nothing for an empty day", () => {
|
|
23
|
-
expect(layoutDayColumns(DAY, [])).toEqual([]);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("stacks non-overlapping events in a single column", () => {
|
|
27
|
-
const g = geom([ev("a", 9, 0, 10, 0), ev("b", 11, 0, 12, 0)]);
|
|
28
|
-
expect(g.a).toEqual({ column: 0, columns: 1 });
|
|
29
|
-
expect(g.b).toEqual({ column: 0, columns: 1 });
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("splits two fully overlapping events into two columns", () => {
|
|
33
|
-
const g = geom([ev("a", 9, 0, 10, 0), ev("b", 9, 0, 10, 0)]);
|
|
34
|
-
expect(g.a.columns).toBe(2);
|
|
35
|
-
expect(g.b.columns).toBe(2);
|
|
36
|
-
expect(new Set([g.a.column, g.b.column])).toEqual(new Set([0, 1]));
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("gives three concurrent events three columns", () => {
|
|
40
|
-
const g = geom([ev("a", 9, 0, 12, 0), ev("b", 9, 0, 12, 0), ev("c", 9, 0, 12, 0)]);
|
|
41
|
-
expect(new Set([g.a.column, g.b.column, g.c.column])).toEqual(new Set([0, 1, 2]));
|
|
42
|
-
for (const k of ["a", "b", "c"]) expect(g[k].columns).toBe(3);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("reuses a freed column within a transitive cluster (A→B→C chain)", () => {
|
|
46
|
-
// A 9-10, B 9:30-11 (overlaps A), C 10:30-12 (overlaps B, not A).
|
|
47
|
-
// One cluster via B; 2 columns; C reuses A's column once A ends.
|
|
48
|
-
const g = geom([ev("a", 9, 0, 10, 0), ev("b", 9, 30, 11, 0), ev("c", 10, 30, 12, 0)]);
|
|
49
|
-
expect(g.a.columns).toBe(2);
|
|
50
|
-
expect(g.a.column).toBe(0);
|
|
51
|
-
expect(g.b.column).toBe(1);
|
|
52
|
-
expect(g.c.column).toBe(0); // freed by A
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("treats time-separated events as independent clusters", () => {
|
|
56
|
-
const g = geom([ev("morn1", 9, 0, 10, 0), ev("morn2", 9, 0, 10, 0), ev("aft", 14, 0, 15, 0)]);
|
|
57
|
-
expect(g.morn1.columns).toBe(2);
|
|
58
|
-
expect(g.morn2.columns).toBe(2);
|
|
59
|
-
expect(g.aft).toEqual({ column: 0, columns: 1 }); // own cluster
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("positions and floors height; clamps to the day window", () => {
|
|
63
|
-
const [c] = layoutDayColumns(DAY, [ev("x", 9, 0, 9, 5)]);
|
|
64
|
-
expect(c.topMinutes).toBe(540); // 9:00
|
|
65
|
-
expect(c.heightMinutes).toBeGreaterThanOrEqual(20); // 5-min event floored
|
|
66
|
-
const spill = layoutDayColumns(DAY, [
|
|
67
|
-
{ id: "y", title: "y", start: at(23, 0), end: new Date(2026, 0, 16, 2, 0) },
|
|
68
|
-
])[0];
|
|
69
|
-
expect(spill.topMinutes).toBe(1380); // 23:00
|
|
70
|
-
expect(spill.topMinutes + spill.heightMinutes).toBeLessThanOrEqual(1440); // clamped
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe("packEventLanes", () => {
|
|
75
|
-
const WEEK = new Date(2026, 0, 12); // Mon
|
|
76
|
-
const allDay = (id: string, startDay: number, endDay: number): CalendarEvent => ({
|
|
77
|
-
id,
|
|
78
|
-
title: id,
|
|
79
|
-
start: new Date(2026, 0, 12 + startDay),
|
|
80
|
-
end: new Date(2026, 0, 12 + endDay),
|
|
81
|
-
allDay: true,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("places non-overlapping spans in one lane", () => {
|
|
85
|
-
const { bars, lanes } = packEventLanes([allDay("a", 0, 1), allDay("b", 3, 4)], WEEK, 7);
|
|
86
|
-
expect(lanes).toBe(1);
|
|
87
|
-
expect(bars.find((b) => b.event.id === "a")).toMatchObject({ startCol: 0, span: 2, lane: 0 });
|
|
88
|
-
expect(bars.find((b) => b.event.id === "b")).toMatchObject({ startCol: 3, span: 2, lane: 0 });
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("stacks overlapping multi-day spans into separate lanes", () => {
|
|
92
|
-
const { lanes } = packEventLanes([allDay("a", 0, 3), allDay("b", 2, 5)], WEEK, 7);
|
|
93
|
-
expect(lanes).toBe(2);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("clamps a span that starts before the window and filters out-of-range", () => {
|
|
97
|
-
const before: CalendarEvent = { id: "x", title: "x", start: new Date(2026, 0, 9), end: new Date(2026, 0, 13), allDay: true };
|
|
98
|
-
const after = allDay("y", 9, 10); // entirely after a 7-day window
|
|
99
|
-
const { bars } = packEventLanes([before, after], WEEK, 7);
|
|
100
|
-
expect(bars).toHaveLength(1);
|
|
101
|
-
expect(bars[0]).toMatchObject({ event: { id: "x" }, startCol: 0, span: 2 }); // clamped to days 0..1
|
|
102
|
-
});
|
|
103
|
-
});
|
package/src/colors.test.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { isColorName, asColorName } from "./colors";
|
|
3
|
-
|
|
4
|
-
describe("isColorName", () => {
|
|
5
|
-
it("accepts palette family names", () => {
|
|
6
|
-
expect(isColorName("blue")).toBe(true);
|
|
7
|
-
expect(isColorName("emerald")).toBe(true);
|
|
8
|
-
expect(isColorName("zinc")).toBe(true);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it("rejects role keys and scalar colors (not selectable families)", () => {
|
|
12
|
-
expect(isColorName("border")).toBe(false);
|
|
13
|
-
expect(isColorName("border_shadow")).toBe(false);
|
|
14
|
-
expect(isColorName("background")).toBe(false);
|
|
15
|
-
expect(isColorName("shadow")).toBe(false);
|
|
16
|
-
expect(isColorName("black")).toBe(false);
|
|
17
|
-
expect(isColorName("white")).toBe(false);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("rejects unknown tokens and non-strings", () => {
|
|
21
|
-
expect(isColorName("chartreuse")).toBe(false);
|
|
22
|
-
expect(isColorName("")).toBe(false);
|
|
23
|
-
expect(isColorName(undefined)).toBe(false);
|
|
24
|
-
expect(isColorName(null)).toBe(false);
|
|
25
|
-
expect(isColorName(42)).toBe(false);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe("asColorName", () => {
|
|
30
|
-
it("passes a valid family through", () => {
|
|
31
|
-
expect(asColorName("purple")).toBe("purple");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("degrades an unknown/absent token to the neutral default", () => {
|
|
35
|
-
// The graceful-degradation contract: a select option whose color token this
|
|
36
|
-
// build doesn't recognize, or none at all, renders neutral rather than break.
|
|
37
|
-
expect(asColorName("chartreuse")).toBe("zinc");
|
|
38
|
-
expect(asColorName(undefined)).toBe("zinc");
|
|
39
|
-
expect(asColorName(null)).toBe("zinc");
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("honors a custom fallback", () => {
|
|
43
|
-
expect(asColorName(undefined, "slate")).toBe("slate");
|
|
44
|
-
});
|
|
45
|
-
});
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { getPresetValue, PRESET_IDS, type PresetId } from "./date_filter_presets";
|
|
3
|
-
|
|
4
|
-
// Wednesday, 10 June 2026, 14:30 — a fixed clock so every range is deterministic.
|
|
5
|
-
const NOW = new Date(2026, 5, 10, 14, 30, 0, 0);
|
|
6
|
-
|
|
7
|
-
function ymd(d: Date | null): string {
|
|
8
|
-
if (!d) return "null";
|
|
9
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe("getPresetValue", () => {
|
|
13
|
-
it("today spans the start to end of the current day", () => {
|
|
14
|
-
const v = getPresetValue("today", NOW)!;
|
|
15
|
-
expect(ymd(v.start.date)).toBe("2026-06-10");
|
|
16
|
-
expect(ymd(v.end.date)).toBe("2026-06-10");
|
|
17
|
-
expect(v.start.date!.getHours()).toBe(0);
|
|
18
|
-
expect(v.start.date!.getMilliseconds()).toBe(0);
|
|
19
|
-
expect(v.end.date!.getHours()).toBe(23);
|
|
20
|
-
expect(v.end.date!.getMinutes()).toBe(59);
|
|
21
|
-
expect(v.end.date!.getMilliseconds()).toBe(999);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("yesterday and tomorrow shift by one day", () => {
|
|
25
|
-
expect(ymd(getPresetValue("yesterday", NOW)!.start.date)).toBe("2026-06-09");
|
|
26
|
-
expect(ymd(getPresetValue("yesterday", NOW)!.end.date)).toBe("2026-06-09");
|
|
27
|
-
expect(ymd(getPresetValue("tomorrow", NOW)!.start.date)).toBe("2026-06-11");
|
|
28
|
-
expect(ymd(getPresetValue("tomorrow", NOW)!.end.date)).toBe("2026-06-11");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("this_week runs Monday → Sunday around now", () => {
|
|
32
|
-
const v = getPresetValue("this_week", NOW)!;
|
|
33
|
-
expect(ymd(v.start.date)).toBe("2026-06-08"); // Monday
|
|
34
|
-
expect(v.start.date!.getDay()).toBe(1);
|
|
35
|
-
expect(ymd(v.end.date)).toBe("2026-06-14"); // Sunday
|
|
36
|
-
expect(v.end.date!.getDay()).toBe(0);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("this_month covers the whole calendar month", () => {
|
|
40
|
-
const v = getPresetValue("this_month", NOW)!;
|
|
41
|
-
expect(ymd(v.start.date)).toBe("2026-06-01");
|
|
42
|
-
expect(ymd(v.end.date)).toBe("2026-06-30");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("last_month covers the previous calendar month", () => {
|
|
46
|
-
const v = getPresetValue("last_month", NOW)!;
|
|
47
|
-
expect(ymd(v.start.date)).toBe("2026-05-01");
|
|
48
|
-
expect(ymd(v.end.date)).toBe("2026-05-31");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("custom carries no range", () => {
|
|
52
|
-
expect(getPresetValue("custom", NOW)).toBeNull();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("every preset id resolves (only custom is null)", () => {
|
|
56
|
-
for (const id of PRESET_IDS) {
|
|
57
|
-
const v = getPresetValue(id as PresetId, NOW);
|
|
58
|
-
if (id === "custom") expect(v).toBeNull();
|
|
59
|
-
else expect(v).not.toBeNull();
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
});
|