diff --git a/semcore/core/__tests__/decorators.test.ts b/semcore/core/__tests__/decorators.test.ts new file mode 100644 index 0000000000..2530f11ab9 --- /dev/null +++ b/semcore/core/__tests__/decorators.test.ts @@ -0,0 +1,481 @@ +import { describe, it, expect, vi, Test } from '@semcore/testing-utils/vitest'; + +import reactive from '../src/decorators/reactive'; +import trackPropsChanges from '../src/decorators/trackPropsChanges'; + +interface TestProps { + name: string; + age: number; +} + +describe('@reactive', () => { + describe('primitive values', () => { + it('should call callback when primitive value changes', () => { + const callback = vi.fn(); + + class TestClass { + @reactive(callback) + counter = 0; + } + + const instance = new TestClass(); + instance.counter = 5; + + expect(callback).toHaveBeenCalledWith('counter', 5); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should call callback with correct "this" context', () => { + let capturedThis: any; + + class TestClass { + name = 'TestInstance'; + + @reactive(function (this: TestClass) { + capturedThis = this; + }) + value = 10; + } + + const instance = new TestClass(); + instance.value = 20; + + expect(capturedThis).toBe(instance); + expect(capturedThis.name).toBe('TestInstance'); + }); + + it('should call callback multiple times on multiple changes', () => { + const callback = vi.fn(); + + class TestClass { + @reactive(callback) + counter = 0; + } + + const instance = new TestClass(); + instance.counter = 1; + instance.counter = 2; + instance.counter = 3; + + expect(callback).toHaveBeenCalledTimes(3); + expect(callback).toHaveBeenNthCalledWith(1, 'counter', 1); + expect(callback).toHaveBeenNthCalledWith(2, 'counter', 2); + expect(callback).toHaveBeenNthCalledWith(3, 'counter', 3); + }); + }); + describe('non-primitive values (objects)', () => { + it('should call callback when watched field changes in object', () => { + const callback = vi.fn(); + + class TestClass { + @reactive(['name'], callback) + readonly user = { name: 'John', age: 30 }; + } + + const instance = new TestClass(); + instance.user.name = 'Jane'; + + expect(callback).toHaveBeenCalledWith('name', 'Jane'); + expect(callback).toHaveBeenCalledTimes(1); + }); + it('should NOT call callback when non-watched field changes', () => { + const callback = vi.fn(); + + class TestClass { + @reactive(['name'], callback) + readonly user = { name: 'John', age: 30 }; + } + + const instance = new TestClass(); + instance.user.age = 31; + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should watch multiple fields in object', () => { + const callback = vi.fn(); + + class TestClass { + @reactive(['name', 'age'], callback) + readonly user = { name: 'John', age: 30, city: 'NYC' }; + } + + const instance = new TestClass(); + + instance.user.name = 'Jane'; + expect(callback).toHaveBeenCalledWith('name', 'Jane'); + + instance.user.age = 31; + expect(callback).toHaveBeenCalledWith('age', 31); + + instance.user.city = 'LA'; + expect(callback).toHaveBeenCalledTimes(2); // city is not watched + }); + + it('should call callback with correct "this" context for objects', () => { + let capturedThis: any; + + class TestClass { + id = 'test-123'; + + @reactive(['value'], function (this: TestClass) { + capturedThis = this; + }) + readonly data = { value: 0 }; + } + + const instance = new TestClass(); + instance.data.value = 42; + + expect(capturedThis).toBe(instance); + expect(capturedThis.id).toBe('test-123'); + }); + + it('should work with arrays', () => { + const callback = vi.fn(); + + class TestClass { + @reactive(['0', '1'], callback) + readonly items = ['a', 'b', 'c']; + } + + const instance = new TestClass(); + + instance.items[0] = 'x'; + expect(callback).toHaveBeenCalledWith('0', 'x'); + + instance.items[1] = 'y'; + expect(callback).toHaveBeenCalledWith('1', 'y'); + + instance.items[2] = 'z'; + expect(callback).toHaveBeenCalledTimes(2); // index 2 not watched + }); + + it('should not be called for every change when watchedFields is not defined', () => { + const arrCallback = vi.fn(); + const objCallback = vi.fn(); + + class TestClass { + @reactive(arrCallback) + readonly items = ['a', 'b', 'c']; + + @reactive(objCallback) + obj = { a: 1, b: 2, c: 3 }; + } + + const instance = new TestClass(); + + instance.items[0] = 'x'; + expect(arrCallback).not.toHaveBeenCalled(); + + instance.items[1] = 'y'; + expect(arrCallback).not.toHaveBeenCalled(); + + instance.items[2] = 'z'; + expect(arrCallback).not.toHaveBeenCalled(); + + expect(arrCallback).not.toHaveBeenCalled(); + + instance.obj.a = 4; + expect(objCallback).not.toHaveBeenCalled(); + + instance.obj.b = 5; + expect(objCallback).not.toHaveBeenCalled(); + + instance.obj.c = 6; + expect(objCallback).not.toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('should handle empty watchedFields array for objects', () => { + const callback = vi.fn(); + + class TestClass { + @reactive([], callback) + readonly data = { value: 0 }; + } + + const instance = new TestClass(); + instance.data.value = 42; + + expect(callback).toHaveBeenCalledOnce(); + }); + + it('should work when setting same value multiple times', () => { + const callback = vi.fn(); + + class TestClass { + @reactive(callback) + value = 0; + } + + const instance = new TestClass(); + instance.value = 5; + instance.value = 5; + instance.value = 5; + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('@trackPropsChanges', () => { + describe('Basic functionality', () => { + it('should track specified props changes', () => { + const onPropsChangeSpy = vi.fn(); + + @trackPropsChanges(['name', 'age']) + class TestComponent { + props: TestProps; + + constructor(props: TestProps) { + this.props = props; + } + + onPropsChange(changedProps: Partial) { + onPropsChangeSpy(changedProps); + } + + render() { + return 'rendered'; + } + } + + const component = new TestComponent({ name: 'John', age: 30 }); + + component.render(); + expect(onPropsChangeSpy).not.toHaveBeenCalled(); + + component.props = { name: 'Jane', age: 30 }; + component.render(); + + expect(onPropsChangeSpy).toHaveBeenCalledWith({ name: 'Jane' }); + expect(onPropsChangeSpy).toHaveBeenCalledTimes(1); + }); + + it('should detect multiple props changes', () => { + const onPropsChangeSpy = vi.fn(); + + @trackPropsChanges(['name', 'age']) + class TestComponent { + props: TestProps; + + constructor(props: TestProps) { + this.props = props; + } + + onPropsChange(changedProps: Partial) { + onPropsChangeSpy(changedProps); + } + + render() { + return 'rendered'; + } + } + + const component = new TestComponent({ name: 'John', age: 30 }); + component.render(); + + component.props = { name: 'Jane', age: 25 }; + component.render(); + + expect(onPropsChangeSpy).toHaveBeenCalledWith({ name: 'Jane', age: 25 }); + }); + + it('should not call onPropsChange when no tracked props changed', () => { + const onPropsChangeSpy = vi.fn(); + + @trackPropsChanges(['name']) + class TestComponent { + props: TestProps; + + constructor(props: TestProps) { + this.props = props; + } + + onPropsChange(changedProps: Partial) { + onPropsChangeSpy(changedProps); + } + + render() { + return 'rendered'; + } + } + + const component = new TestComponent({ name: 'John', age: 30 }); + component.render(); + + component.props = { name: 'John', age: 25 }; + component.render(); + + expect(onPropsChangeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Reference equality for objects and arrays', () => { + it('should detect object reference changes', () => { + const onPropsChangeSpy = vi.fn(); + + interface ObjectProps { + user: { name: string }; + } + + @trackPropsChanges(['user']) + class TestComponent { + props: ObjectProps; + + constructor(props: ObjectProps) { + this.props = props; + } + + onPropsChange(changedProps: Partial) { + onPropsChangeSpy(changedProps); + } + + render() { + return 'rendered'; + } + } + + const user = { name: 'John' }; + const component = new TestComponent({ user }); + component.render(); + + component.props = { user }; + component.render(); + expect(onPropsChangeSpy).not.toHaveBeenCalled(); + + component.props = { user: { name: 'John' } }; + component.render(); + expect(onPropsChangeSpy).toHaveBeenCalledWith({ user: { name: 'John' } }); + }); + + it('should detect array reference changes', () => { + const onPropsChangeSpy = vi.fn(); + + interface ArrayProps { + items: string[]; + } + + @trackPropsChanges(['items']) + class TestComponent { + props: ArrayProps; + + constructor(props: ArrayProps) { + this.props = props; + } + + onPropsChange(changedProps: Partial) { + onPropsChangeSpy(changedProps); + } + + render() { + return 'rendered'; + } + } + + const items = ['a', 'b']; + const component = new TestComponent({ items }); + component.render(); + + component.props = { items: ['a', 'b'] }; + component.render(); + + expect(onPropsChangeSpy).toHaveBeenCalledWith({ items: ['a', 'b'] }); + }); + }); + + describe('Edge cases', () => { + it('should handle empty propsToWatch array', () => { + const onPropsChangeSpy = vi.fn(); + + @trackPropsChanges([]) + class TestComponent { + props: TestProps; + + constructor(props: TestProps) { + this.props = props; + } + + onPropsChange(changedProps: Partial) { + onPropsChangeSpy(changedProps); + } + + render() { + return 'rendered'; + } + } + + const component = new TestComponent({ name: 'John', age: 30 }); + + component.render(); + + component.props = { name: 'Jane', age: 25 }; + component.render(); + + expect(onPropsChangeSpy).not.toHaveBeenCalled(); + }); + + it('should work with multiple renders without prop changes', () => { + const onPropsChangeSpy = vi.fn(); + + @trackPropsChanges(['name']) + class TestComponent { + props: TestProps; + + constructor(props: TestProps) { + this.props = props; + } + + onPropsChange(changedProps: Partial) { + onPropsChangeSpy(changedProps); + } + + render() { + return 'rendered'; + } + } + + const component = new TestComponent({ name: 'John', age: 30 }); + + component.render(); + component.render(); + component.render(); + + expect(onPropsChangeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Render behavior', () => { + it('should call onPropsChange before parent render', () => { + const callOrder: string[] = []; + + @trackPropsChanges(['name']) + class TestComponent { + props: TestProps; + + constructor(props: TestProps) { + this.props = props; + } + + onPropsChange(changedProps: Partial) { + callOrder.push('onPropsChange'); + } + + render() { + callOrder.push('render'); + return 'rendered'; + } + } + + const component = new TestComponent({ name: 'John', age: 30 }); + component.render(); + + component.props = { name: 'Jane', age: 30 }; + component.render(); + + expect(callOrder).toEqual(['render', 'onPropsChange', 'render']); + }); + }); +}); diff --git a/semcore/core/src/decorators/reactive.ts b/semcore/core/src/decorators/reactive.ts new file mode 100644 index 0000000000..193620b3d3 --- /dev/null +++ b/semcore/core/src/decorators/reactive.ts @@ -0,0 +1,78 @@ +type Primitive = string | number | boolean | symbol | bigint | null | undefined; +type IsReadonly = + (() => F extends { [P in Property]: This[Property] } ? 1 : 2) extends + (() => F extends { -readonly [P in Property]: This[Property] } ? 1 : 2) + ? false + : true; +type Callback = (this: This, field: string | symbol, newValue: any) => void; +type ReturnType< + This, + Property extends keyof This = keyof This, +> = IsReadonly extends true + ? (_: undefined, ctx: ClassFieldDecoratorContext) => void + : This[Property] extends Primitive + ? (_: undefined, ctx: ClassFieldDecoratorContext) => void + : never; + +const isPrimitiveValue = (value: any) => value !== Object(value); + +function reactive(cb: Callback): ReturnType; +function reactive< + This, + Property extends keyof This, + Value = This[Property], +>(watchedFields: Value extends Primitive ? never : Array, cb: Callback): ReturnType; + +function reactive< + This, + Property extends keyof This, +>(watchedFieldsOrCb: Array | Callback, cb?: Callback) { + return function (_: undefined, ctx: ClassFieldDecoratorContext) { + const { addInitializer, name } = ctx; + + addInitializer(function (this: This) { + const thisRoot = this; + + const isPrimitive = isPrimitiveValue(this[name as Property]); + + const callback = typeof watchedFieldsOrCb === 'function' ? watchedFieldsOrCb : cb!; + const fields = Array.isArray(watchedFieldsOrCb) ? watchedFieldsOrCb : null; + + if (isPrimitive) { + let value = this[name as Property]; + + Object.defineProperty(this, name, { + get() { + return value; + }, + set(newValue) { + const oldValue = value; + + value = newValue; + + if (oldValue !== newValue) { + callback.call(thisRoot, name, newValue); + } + }, + enumerable: true, + configurable: true, + }); + } else { + // @ts-ignore + this[name] = new Proxy(this[name], { + set(target, p, newValue) { + target[p] = newValue; + + if (fields?.length === 0 || fields?.includes(p as keyof This[Property])) { + callback.call(thisRoot, p, newValue); + } + + return true; + }, + }); + } + }); + }; +} + +export default reactive; diff --git a/semcore/core/src/decorators/trackPropsChanges.ts b/semcore/core/src/decorators/trackPropsChanges.ts new file mode 100644 index 0000000000..c934226700 --- /dev/null +++ b/semcore/core/src/decorators/trackPropsChanges.ts @@ -0,0 +1,56 @@ +type WatchedProps = { [key in keyof Props]?: any }; + +type Constructor = new (...args: any) => { + props: Props; + onPropsChange(changedProps: WatchedProps): void; + render(): any; +}; + +function trackPropsChanges< + P, + C extends Constructor

= Constructor

, +>(propsToWatch: Array) { + const watchedProps: WatchedProps

= {}; + + return function (Class: C) { + return class extends Class { + constructor(...args: any[]) { + super(...args); + + propsToWatch.reduce((acc, prop) => { + acc[prop] = this.props?.[prop]; + + return acc; + }, watchedProps); + } + + onPropsChange(_?: WatchedProps

) { + let shouldCallFunc = false; + const changedProps: WatchedProps

= {}; + + propsToWatch.forEach((prop) => { + const isPropValueEqual = Object.is(watchedProps[prop], this.props[prop]); + + if (!isPropValueEqual) { + watchedProps[prop] = this.props[prop]; + changedProps[prop] = this.props[prop]; + + shouldCallFunc = true; + } + }); + + if (!shouldCallFunc) return; + + super.onPropsChange(changedProps); + } + + render() { + this.onPropsChange(); + + return super.render(); + } + }; + }; +} + +export default trackPropsChanges; diff --git a/semcore/time-picker/__tests__/index.test.jsx b/semcore/time-picker/__tests__/index.test.jsx deleted file mode 100644 index 990bcdbcc1..0000000000 --- a/semcore/time-picker/__tests__/index.test.jsx +++ /dev/null @@ -1,6 +0,0 @@ -import { runDependencyCheckTests } from '@semcore/testing-utils/shared-tests'; -import { describe } from '@semcore/testing-utils/vitest'; - -describe('time-picker Dependency imports', () => { - runDependencyCheckTests('time-picker'); -}); diff --git a/semcore/time-picker/__tests__/index.test.tsx b/semcore/time-picker/__tests__/index.test.tsx new file mode 100644 index 0000000000..bbcaa86719 --- /dev/null +++ b/semcore/time-picker/__tests__/index.test.tsx @@ -0,0 +1,231 @@ +import { runDependencyCheckTests } from '@semcore/testing-utils/shared-tests'; +import { describe, it, expect } from '@semcore/testing-utils/vitest'; + +import TimePickerEntity from '../src/entity/TimePickerEntity'; + +describe('time-picker Dependency imports', () => { + runDependencyCheckTests('time-picker'); +}); + +describe('TimePickerEntity', () => { + describe('constructor', () => { + it('should initialize with default empty time when no value provided', () => { + const entity = new TimePickerEntity(':', false); + + expect(entity.hours).toBe(''); + expect(entity.minutes).toBe(''); + }); + + it('should parse hours and minutes from value string', () => { + const entity = new TimePickerEntity('14:30', false); + + expect(entity.hours).toBe('14'); + expect(entity.minutes).toBe('30'); + }); + + it('should handle single digit hours and minutes', () => { + const entity = new TimePickerEntity('9:5', false); + + expect(entity.hours).toBe('09'); + expect(entity.minutes).toBe('05'); + }); + + it('should initialize with AM meridiem by default', () => { + const entity = new TimePickerEntity('10:00', true); + + expect(entity.meridiem).toBe('AM'); + }); + }); + + describe('12-hour format', () => { + it('should format midnight (00:00) as 12:00 AM', () => { + const entity = new TimePickerEntity('0:00', true); + + expect(entity.hours).toBe('12'); + expect(entity.minutes).toBe('00'); + }); + + it('should format hours 1-11 AM correctly', () => { + const entity = new TimePickerEntity('9:30', true); + + expect(entity.hours).toBe('09'); + }); + + it('should format noon (12:00) as 12:00', () => { + const entity = new TimePickerEntity('12:00', true); + + expect(entity.hours).toBe('12'); + }); + + it('should format hours 13-23 as 1-11 PM', () => { + const entity = new TimePickerEntity('14:45', true); + + expect(entity.hours).toBe('02'); + }); + + it('should format 23:59 as 11:59', () => { + const entity = new TimePickerEntity('23:59', true); + + expect(entity.hours).toBe('11'); + expect(entity.minutes).toBe('59'); + }); + }); + + describe('24-hour format', () => { + it('should format hours with leading zero in 24-hour mode', () => { + const entity = new TimePickerEntity('9:30', false); + + expect(entity.hours).toBe('09'); + }); + + it('should handle midnight in 24-hour format', () => { + const entity = new TimePickerEntity('0:00', false); + + expect(entity.hours).toBe('00'); + }); + + it('should convert 12 AM to 00:00 in 24-hour format', () => { + const entity = new TimePickerEntity('12:00', false); + entity.meridiem = 'AM'; + + expect(entity.hours).toBe('00'); + }); + + it('should convert 12 PM to 12:00 in 24-hour format', () => { + const entity = new TimePickerEntity('12:00', false); + entity.meridiem = 'PM'; + + expect(entity.hours).toBe('12'); + }); + + it('should convert PM hours correctly', () => { + const entity = new TimePickerEntity('3:00', false); + entity.meridiem = 'PM'; + + expect(entity.hours).toBe('15'); + }); + + it('should keep AM hours unchanged (except 12)', () => { + const entity = new TimePickerEntity('9:00', false); + entity.meridiem = 'AM'; + + expect(entity.hours).toBe('09'); + }); + }); + + describe('getters and setters', () => { + it('should update hours via setter', () => { + const entity = new TimePickerEntity('10:00', false); + entity.hours = '15'; + + expect(entity.hours).toBe('15'); + }); + + it('should update minutes via setter', () => { + const entity = new TimePickerEntity('10:00', false); + entity.minutes = '45'; + + expect(entity.minutes).toBe('45'); + }); + + it('should update meridiem via setter', () => { + const entity = new TimePickerEntity('10:00', true); + entity.meridiem = 'PM'; + + expect(entity.meridiem).toBe('PM'); + }); + }); + + describe('toggleMeridiem', () => { + it('should toggle from AM to PM', () => { + const entity = new TimePickerEntity('10:00', true); + + entity.toggleMeridiem(); + + expect(entity.meridiem).toBe('PM'); + }); + + it('should toggle from PM to AM', () => { + const entity = new TimePickerEntity('10:00', true); + entity.meridiem = 'PM'; + + entity.toggleMeridiem(); + + expect(entity.meridiem).toBe('AM'); + }); + + it('should toggle multiple times correctly', () => { + const entity = new TimePickerEntity('10:00', true); + + entity.toggleMeridiem(); + expect(entity.meridiem).toBe('PM'); + + entity.toggleMeridiem(); + expect(entity.meridiem).toBe('AM'); + + entity.toggleMeridiem(); + expect(entity.meridiem).toBe('PM'); + }); + }); + + describe('toString', () => { + it('should return 24-hour format string when is12Hour is false', () => { + const entity = new TimePickerEntity('14:30', false); + + expect(entity.toString()).toBe('14:30'); + }); + + it('should convert to 24-hour format string when is12Hour is true', () => { + const entity = new TimePickerEntity('2:30', true); + entity.meridiem = 'PM'; + + expect(entity.toString()).toBe('14:30'); + }); + + it('should handle midnight (12 AM) conversion', () => { + const entity = new TimePickerEntity('12:00', true); + entity.meridiem = 'AM'; + + expect(entity.toString()).toBe('00:00'); + }); + + it('should handle noon (12 PM) conversion', () => { + const entity = new TimePickerEntity('12:00', true); + entity.meridiem = 'PM'; + + expect(entity.toString()).toBe('12:00'); + }); + + it('should add leading zeros to output', () => { + const entity = new TimePickerEntity('9:5', false); + + expect(entity.toString()).toBe('09:05'); + }); + }); + + describe('edge cases', () => { + it('should handle invalid hours gracefully', () => { + const entity = new TimePickerEntity('invalid:30', false); + + expect(entity.hours).toBe('invalid'); + }); + + it('should handle empty minutes', () => { + const entity = new TimePickerEntity('10:', false); + + expect(entity.minutes).toBe(''); + }); + + it('should handle empty hours', () => { + const entity = new TimePickerEntity(':30', false); + + expect(entity.hours).toBe(''); + }); + + it('should preserve non-numeric hour values in 12-hour format', () => { + const entity = new TimePickerEntity('invalid:30', true); + + expect(entity.hours).toBe('invalid'); + }); + }); +}); diff --git a/semcore/time-picker/package.json b/semcore/time-picker/package.json index 6ef9e19833..9cae9060ac 100644 --- a/semcore/time-picker/package.json +++ b/semcore/time-picker/package.json @@ -9,7 +9,7 @@ "author": "UI-kit team ", "license": "MIT", "scripts": { - "build": "pnpm semcore-builder --source=js && pnpm vite build" + "build": "pnpm semcore-builder && pnpm vite build" }, "exports": { "require": "./lib/cjs/index.js", diff --git a/semcore/time-picker/src/TimePicker.jsx b/semcore/time-picker/src/TimePicker.jsx deleted file mode 100644 index 460825b080..0000000000 --- a/semcore/time-picker/src/TimePicker.jsx +++ /dev/null @@ -1,278 +0,0 @@ -import { createComponent, Component, sstyled, Root } from '@semcore/core'; -import i18nEnhance from '@semcore/core/lib/utils/enhances/i18nEnhance'; -import { Box } from '@semcore/flex-box'; -import Input from '@semcore/input'; -import React from 'react'; - -import Format from './PickerFormat'; -import { Hours, Minutes } from './PickerInput'; -import style from './style/time-picker.shadow.css'; -import { localizedMessages } from './translations/__intergalactic-dynamic-locales'; - -const MAP_MERIDIEM = { - AM: 'PM', - PM: 'AM', -}; -const MAP_FIELD_TO_TIME = { - hours: 0, - minutes: 1, -}; - -export function intOrDefault(value, def = 0) { - const number = Number.parseInt(value); - return Number.isNaN(number) ? def : number; -} - -export function withLeadingZero(value) { - value = String(value); - if (value.length === 1) return `0${value}`; - return String(value); -} - -export function meridiemByHours(hours) { - return hours >= 12 ? 'PM' : 'AM'; -} - -export function formatHoursTo12(hours /* hours by 24 */) { - const nHours = intOrDefault(hours, Number.NaN); // if not (:00) - if (Number.isNaN(nHours)) return hours; - - // if not (HH:00) - if (nHours === 0) return 12; // 0 => 12 PM - if (nHours > 12) return nHours - 12; // 22 => 10 PM - - return hours; -} - -export function formatHoursTo24(hours /* hours by 12 */, meridiem) { - const nHours = intOrDefault(hours, Number.NaN); // if not (:00) - - if (Number.isNaN(nHours)) return hours; - - if (meridiem === 'AM') { - if (nHours === 12) return 0; // 12 AM => 0 - } - - if (meridiem === 'PM') { - if (nHours < 12) return nHours + 12; // 10 PM => 22 - } - - return hours; -} - -class TimePickerRoot extends Component { - static displayName = 'TimePicker'; - static style = style; - static enhance = [i18nEnhance(localizedMessages)]; - static defaultProps = ({ is12Hour }) => ({ - defaultValue: '', - size: 'm', - children: ( - <> - - - - {is12Hour && } - - ), - i18n: localizedMessages, - locale: 'en', - defaultTitle: '', - }); - - hoursInputRef = React.createRef(); - minutesInputRef = React.createRef(); - - _lastMeridiem = 'AM'; // default AM - - uncontrolledProps() { - return { - value: null, - title: null, - }; - } - - componentDidMount() { - const { id, 'aria-describedby': ariaDescribedBy } = this.asProps; - const selector = `[for=${id}]`; - const titleElement = - document.querySelector(selector) ?? document.querySelector(`#${ariaDescribedBy}`); - - if (titleElement) { - this.handlers.title(titleElement.textContent); - } - } - - get value() { - const { value } = this.asProps; - return value === null || value === undefined ? ':' : value; - } - - get meridiem() { - const { value } = this.asProps; - const [hours = ''] = value.split(':'); - - const nHours = intOrDefault(hours, Number.NaN); - - if (!Number.isNaN(nHours)) { - this._lastMeridiem = meridiemByHours(nHours); - } - - return this._lastMeridiem; - } - - valueToTime(value) { - const { is12Hour } = this.asProps; - let [hours = '', minutes = ''] = value.split(':'); - - if (is12Hour) hours = formatHoursTo12(hours); - - hours = withLeadingZero(hours); - minutes = withLeadingZero(minutes); - - return [hours, minutes]; - } - - timeToValue(time, meridiem) { - const { is12Hour } = this.asProps; - let [hours = '', minutes = ''] = time; - - hours = intOrDefault(hours, hours); // 03 => 3 - minutes = intOrDefault(minutes, minutes); // MM => MM - - if (is12Hour) hours = formatHoursTo24(hours, meridiem); // 12 PM -> 0 - - return `${hours}:${minutes}`; - } - - handleValueChange = (value, field, event) => { - const { is12Hour } = this.asProps; - const { meridiem } = this; - - let time; - if (field) { - time = this.value.split(':'); - time[MAP_FIELD_TO_TIME[field]] = value; - } else { - time = value.split(':'); - } - - let [hours = '', minutes = ''] = time; - - if (is12Hour) hours = String(formatHoursTo12(hours)); - - value = this.timeToValue([hours, minutes], meridiem); - this.handlers.value(value, event); - }; - - handleMeridiemClick = (event) => { - const { is12Hour } = this.asProps; - let { value, meridiem } = this; - let [hours = '', minutes = ''] = value.split(':'); - - if (is12Hour) hours = String(formatHoursTo12(hours)); - - value = this.timeToValue([hours, minutes], MAP_MERIDIEM[meridiem]); - this.handlers.value(value, event); - }; - - _getHoursAndMinutesProps = () => { - const { is12Hour, size, disabled, getI18nText } = this.asProps; - const time = this.valueToTime(this.value); - - return { - time, - size, - is12Hour, - disabled, - $onValueChange: this.handleValueChange, - minutesInputRef: this.minutesInputRef, - hoursInputRef: this.hoursInputRef, - _getI18nText: getI18nText, - }; - }; - - getHoursProps = () => { - return { ...this._getHoursAndMinutesProps(), ref: this.hoursInputRef }; - }; - - getMinutesProps = () => { - return { ...this._getHoursAndMinutesProps(), ref: this.minutesInputRef }; - }; - - getSeparatorProps() { - return { - disabled: this.asProps.disabled, - hoursInputRef: this.hoursInputRef, - }; - } - - getFormatProps() { - const { size, disabled, disablePortal, getI18nText } = this.asProps; - - return { - size, - disabled, - disablePortal, - meridiem: this.meridiem, - onClick: this.handleMeridiemClick, - getI18nText, - }; - } - - render() { - const STimePicker = Root; - const { styles, Children, value, is12Hour, getI18nText, title } = this.asProps; - const [hours, minutes] = this.valueToTime(this.value); - - const label = value - ? `${title} ${getI18nText('title', { - time: `${hours}:${withLeadingZero(minutes)}`, - meridiem: is12Hour ? this.meridiem : '', - })}` - : `${title} ${getI18nText('titleEmpty')}`; - - return sstyled(styles)( - <> - - - - , - ); - } -} - -class Separator extends Component { - static defaultProps = { - children: ':', - }; - - handlerClick = () => { - if (this.asProps.hoursInputRef.current) { - this.asProps.hoursInputRef.current?.focus(); - } - }; - - render() { - const STimePickerSeparator = Root; - const { styles } = this.asProps; - - return sstyled(styles)( -