@ecohouse/ui 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecohouse/ui",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Cross-platform presentation-only UI components for EcoHouse (React Native + React Native Web)",
@@ -0,0 +1,128 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { Range } from "./Range";
4
+ import type { RangeValue } from "./Range.types";
5
+
6
+ const meta = {
7
+ title: "Components/Range",
8
+ component: Range,
9
+ args: {
10
+ min: 0,
11
+ max: 1_000_000,
12
+ step: 10_000,
13
+ fromPlaceholder: "Սկսած",
14
+ toPlaceholder: "Մինչև",
15
+ currency: "֏",
16
+ },
17
+ argTypes: {
18
+ min: { control: "number" },
19
+ max: { control: "number" },
20
+ step: { control: "number" },
21
+ disabled: { control: "boolean" },
22
+ onValueChange: { action: "valueChange" },
23
+ },
24
+ parameters: {
25
+ backgrounds: { default: "black" },
26
+ layout: "centered",
27
+ },
28
+ } satisfies Meta<typeof Range>;
29
+
30
+ export default meta;
31
+ type Story = StoryObj<typeof meta>;
32
+
33
+ export const Default: Story = {};
34
+
35
+ export const English: Story = {
36
+ args: {
37
+ fromPlaceholder: "From",
38
+ toPlaceholder: "To",
39
+ fromAccessibilityLabel: "Minimum price",
40
+ toAccessibilityLabel: "Maximum price",
41
+ },
42
+ };
43
+
44
+ export const Russian: Story = {
45
+ args: {
46
+ fromPlaceholder: "От",
47
+ toPlaceholder: "До",
48
+ fromAccessibilityLabel: "Минимальная цена",
49
+ toAccessibilityLabel: "Максимальная цена",
50
+ },
51
+ };
52
+
53
+ export const WithValues: Story = {
54
+ args: {
55
+ defaultValue: { from: 200_000, to: 800_000 },
56
+ },
57
+ };
58
+
59
+ const armenianNumberFormatter = new Intl.NumberFormat("hy-AM");
60
+ const armenianNumberParts = armenianNumberFormatter.formatToParts(-12_345.6);
61
+ const armenianGroupSeparator = armenianNumberParts.find((part) => part.type === "group")?.value;
62
+ const armenianDecimalSeparator = armenianNumberParts.find((part) => part.type === "decimal")?.value;
63
+ const armenianMinusSign = armenianNumberParts.find((part) => part.type === "minusSign")?.value;
64
+
65
+ function parseArmenianNumber(value: string) {
66
+ let normalized = value;
67
+ if (armenianGroupSeparator) {
68
+ normalized = normalized.split(armenianGroupSeparator).join("");
69
+ }
70
+ if (armenianDecimalSeparator && armenianDecimalSeparator !== ".") {
71
+ normalized = normalized.replace(armenianDecimalSeparator, ".");
72
+ }
73
+ if (armenianMinusSign && armenianMinusSign !== "-") {
74
+ normalized = normalized.replace(armenianMinusSign, "-");
75
+ }
76
+ normalized = normalized.replace(/\s/g, "");
77
+ if (!normalized) return 0;
78
+
79
+ const parsed = Number(normalized);
80
+ return Number.isFinite(parsed) ? parsed : undefined;
81
+ }
82
+
83
+ export const LocalizedFormatting: Story = {
84
+ args: {
85
+ defaultValue: { from: 125_000, to: 875_000 },
86
+ formatValue: (value) => armenianNumberFormatter.format(value),
87
+ parseValue: parseArmenianNumber,
88
+ },
89
+ };
90
+
91
+ export const DecimalNonZeroMinimum: Story = {
92
+ args: {
93
+ min: 0.25,
94
+ max: 1,
95
+ step: 0.1,
96
+ defaultValue: { from: 0.35, to: 0.85 },
97
+ currency: "",
98
+ },
99
+ };
100
+
101
+ export const OffStepValues: Story = {
102
+ args: {
103
+ min: 0,
104
+ max: 100,
105
+ step: 10,
106
+ defaultValue: { from: 23, to: 78 },
107
+ currency: "",
108
+ },
109
+ };
110
+
111
+ export const CoincidentHandles: Story = {
112
+ args: {
113
+ defaultValue: { from: 500_000, to: 500_000 },
114
+ },
115
+ };
116
+
117
+ export const Disabled: Story = {
118
+ args: { disabled: true },
119
+ };
120
+
121
+ function ControlledRange() {
122
+ const [value, setValue] = useState<RangeValue>({ from: 250_000, to: 750_000 });
123
+ return <Range min={0} max={1_000_000} step={10_000} value={value} onValueChange={setValue} />;
124
+ }
125
+
126
+ export const Controlled: Story = {
127
+ render: () => <ControlledRange />,
128
+ };
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { clampAndSnap, normalizeRangeValue, parseRangeDraft, snapRangeDelta } from "./Range.utils";
3
+
4
+ describe("normalizeRangeValue", () => {
5
+ it("clamps values to the configured bounds", () => {
6
+ expect(normalizeRangeValue({ from: -10, to: 120 }, 0, 100)).toEqual({
7
+ from: 0,
8
+ to: 100,
9
+ });
10
+ });
11
+
12
+ it("preserves exact in-bounds values entered in the fields", () => {
13
+ expect(normalizeRangeValue({ from: 23, to: 78 }, 0, 100)).toEqual({
14
+ from: 23,
15
+ to: 78,
16
+ });
17
+ });
18
+
19
+ it("orders reversed endpoints", () => {
20
+ expect(normalizeRangeValue({ from: 80, to: 20 }, 0, 100)).toEqual({
21
+ from: 20,
22
+ to: 80,
23
+ });
24
+ });
25
+
26
+ it("preserves empty endpoints", () => {
27
+ expect(normalizeRangeValue({}, 0, 100)).toEqual({
28
+ from: undefined,
29
+ to: undefined,
30
+ });
31
+ });
32
+ });
33
+
34
+ describe("parseRangeDraft", () => {
35
+ it("keeps a cleared field empty even when a custom parser maps an empty string to zero", () => {
36
+ expect(parseRangeDraft("", Number)).toEqual({ kind: "empty" });
37
+ expect(parseRangeDraft(" ", Number)).toEqual({ kind: "empty" });
38
+ });
39
+
40
+ it("distinguishes invalid localized text from an intentionally empty field", () => {
41
+ const parser = (draft: string) => {
42
+ const parsed = Number(draft.replace(/ /g, ""));
43
+ return Number.isFinite(parsed) ? parsed : undefined;
44
+ };
45
+
46
+ expect(parseRangeDraft("12 500", parser)).toEqual({ kind: "value", value: 12_500 });
47
+ expect(parseRangeDraft("not a number", parser)).toEqual({ kind: "invalid" });
48
+ expect(parseRangeDraft("Infinity", () => Number.POSITIVE_INFINITY)).toEqual({
49
+ kind: "invalid",
50
+ });
51
+ });
52
+ });
53
+
54
+ describe("clampAndSnap", () => {
55
+ it("snaps relative to a non-zero minimum", () => {
56
+ expect(clampAndSnap(13, 3, 23, 5)).toBe(13);
57
+ expect(clampAndSnap(15, 3, 23, 5)).toBe(13);
58
+ expect(clampAndSnap(16, 3, 23, 5)).toBe(18);
59
+ });
60
+
61
+ it("preserves decimal step precision", () => {
62
+ expect(clampAndSnap(0.26, 0, 1, 0.1)).toBe(0.3);
63
+ expect(clampAndSnap(0.35, 0.25, 1, 0.1)).toBe(0.35);
64
+ expect(clampAndSnap(3e-7, 0, 1, 1e-7)).toBe(3e-7);
65
+ });
66
+ });
67
+
68
+ describe("snapRangeDelta", () => {
69
+ it("keeps dragging anchored to an exact off-grid input value", () => {
70
+ expect(snapRangeDelta(23, 0.3, 10)).toBe(23);
71
+ expect(snapRangeDelta(23, 5.1, 10)).toBe(33);
72
+ expect(snapRangeDelta(23, -5.1, 10)).toBe(13);
73
+ });
74
+
75
+ it("preserves fractional starting values", () => {
76
+ expect(snapRangeDelta(0.35, 0.11, 0.1)).toBe(0.45);
77
+ });
78
+ });