diff --git a/.claude/skills/contributing/references/best-practices.md b/.claude/skills/contributing/references/best-practices.md index 0ba0b4774..e2292ed63 100644 --- a/.claude/skills/contributing/references/best-practices.md +++ b/.claude/skills/contributing/references/best-practices.md @@ -69,6 +69,120 @@ export class MyControlComponent implements ControlValueAccessor { - Effects in constructor auto-clean up — no need for takeUntilDestroyed - Use effects for syncing derived state (e.g., selected date → input display value) +## Responsive Inputs (Breakpoint Support) + +**Always check the TEDI React equivalent before settling on an API.** If the React component uses `BreakpointSupport` / `useBreakpointProps` on a prop, the Angular component should expose breakpoint support on the equivalent input. Skipping this leaves the Angular library behind on responsive behavior and forces consumers to recreate it manually. + +Two patterns exist for this — pick by need: + +### Pattern A: Per-input `BreakpointInput` (preferred when one or two inputs need to be responsive) + +Use this when a single input (e.g., a flag, count, or variant) should vary by breakpoint without restating sibling inputs. Reference: `tedi/components/content/carousel/carousel-content/carousel-content.component.ts:46-55`, `tedi/components/form/time-field/time-field.component.ts` (`useNativePicker`). + +```typescript +import { + breakpointInput, + BreakpointInput, + BreakpointService, +} from "../../../services/breakpoint/breakpoint.service"; + +readonly useNativePicker = input( + { xs: false }, + { transform: (v: BreakpointInput) => breakpointInput(v) }, +); + +private readonly breakpointService = inject(BreakpointService); + +readonly useNativePickerResolved = computed(() => { + const v = this.useNativePicker(); + if (v.xxl !== undefined && this.breakpointService.isAboveBreakpoint("xxl")()) return v.xxl; + if (v.xl !== undefined && this.breakpointService.isAboveBreakpoint("xl")()) return v.xl; + if (v.lg !== undefined && this.breakpointService.isAboveBreakpoint("lg")()) return v.lg; + if (v.md !== undefined && this.breakpointService.isAboveBreakpoint("md")()) return v.md; + if (v.sm !== undefined && this.breakpointService.isAboveBreakpoint("sm")()) return v.sm; + return v.xs; +}); +``` + +Consumer side: + +```html + + +``` + +Notes: +- `breakpointInput()` wraps a plain `T` into `{ xs: T }`, so both shapes work. +- Always compare with `!== undefined` (not truthy) — `false` and `0` are valid values that must not be skipped. +- Iterate **largest breakpoint first** to apply mobile-first cascade correctly. + +### Pattern B: Per-component breakpoint inputs (preferred when many inputs need to be responsive together) + +Use this when several inputs commonly change together at a given breakpoint and consumers benefit from grouping them. Reference: `tedi/components/navigation/link/link.component.ts:55-105`. + +```typescript +export type LinkInputs = { + variant: LinkVariant; + size: LinkSize; + underline: boolean; +}; + +export class LinkComponent implements BreakpointInputs { + variant = input("default"); + size = input("default"); + underline = input(true); + + xs = input(); + sm = input(); + md = input(); + lg = input(); + xl = input(); + xxl = input(); + + private breakpointService = inject(BreakpointService); + breakpointInputs = computed(() => + this.breakpointService.getBreakpointInputs({ + variant: this.variant(), + size: this.size(), + underline: this.underline(), + xs: this.xs(), sm: this.sm(), md: this.md(), + lg: this.lg(), xl: this.xl(), xxl: this.xxl(), + }), + ); +} +``` + +Consumer side: + +```html +Read more +``` + +### Choosing between A and B + +| Question | Pattern | +|---|---| +| Only one input is responsive? | A | +| Two unrelated inputs are responsive but never change together? | A on each | +| Three or more inputs change as a group per breakpoint? | B | +| React equivalent uses `BreakpointSupport` on the whole component? | B | +| React equivalent uses `BreakpointInput` on a single prop? | A | + +### Storybook + +- Add `parameters.status: { type: ["breakpointSupport"] }` to the meta so the badge shows up. +- For pattern A inputs, set the argType `type.summary` to `"BreakpointInput"` with a detail listing both shapes. +- For pattern B, document the per-breakpoint inputs (`xs`, `sm`, `md`, `lg`, `xl`, `xxl`) in argTypes and category them under `"breakpoint inputs"`. +- Add at least one story that demonstrates a responsive case (e.g., `WithResponsiveX`). + +### Consumer catalog + +In `skills/tedi-angular/references/components.md`, write breakpoint-aware inputs as: + +``` +- `useNativePicker: BreakpointInput = false` — ... Accepts a breakpoint object, e.g. `{ xs: true, md: false }` +``` + ## Naming Conventions | Item | Convention | Example | @@ -270,28 +384,33 @@ export const Default: StoryObj = { }; export const WithReactiveForms: StoryObj = { - decorators: [ - moduleMetadata({ - imports: [MyControlComponent, ReactiveFormsModule, AlertComponent, TextComponent], - }), - ], - render: () => ({ - props: { control: new FormControl('') }, - template: ` - - -
{{ {
+  render: () => {
+    const control = new FormControl('');
+
+    return {
+      props: { control },
+      template: `
+        
+          
+            
+          
+          
+            
+              
{{ {
   value: control.value,
   touched: control.touched,
   dirty: control.dirty
 } | json }}
-
- `, - }), + +
+
+ `, + }; + }, }; ``` -> **Note:** Always display reactive form state using a `` with a `
` block and the `json` pipe. This provides a consistent, scannable debug output across all form component stories. Import `AlertComponent` and `TextComponent` in the story's `moduleMetadata`.
+> **Note:** Always display reactive form state using `tedi-row`/`tedi-col` for layout, a `` with a `
` block and the `json` pipe. This provides a consistent, scannable debug output across all form component stories. Import `RowComponent`, `ColComponent`, `AlertComponent`, and `TextComponent` in the story's `moduleMetadata`.
 
 ### Story Coverage
 Every story file must include:
diff --git a/.claude/skills/contributing/references/new-component.md b/.claude/skills/contributing/references/new-component.md
index 38dbeda68..5a33e6020 100644
--- a/.claude/skills/contributing/references/new-component.md
+++ b/.claude/skills/contributing/references/new-component.md
@@ -19,11 +19,12 @@ Enter plan mode and create a detailed plan covering:
 - **Component name** and selector (`tedi-` prefix)
 - **Category** — which folder under `tedi/components/` it belongs to
 - **API design** — all inputs (with types and defaults), outputs, content projection slots
+- **Responsive inputs** — for each input, check if the React equivalent uses `BreakpointSupport` or `BreakpointInput`. If yes, plan the Angular equivalent (see "Responsive Inputs (Breakpoint Support)" in best-practices.md for pattern A vs B). Even without a React reference, ask: would consumers reasonably need to vary this input per breakpoint? If so, add breakpoint support up front — retrofitting later is a breaking change.
 - **Accessibility** — ARIA roles, keyboard interactions, screen reader behavior, focus management
 - **Dependencies** — existing TEDI components to reuse, third-party libraries if needed
 - **File list** — every file to create
 - **Test plan** — what to test (inputs, outputs, states, keyboard, a11y, form integration if applicable)
-- **Stories plan** — which stories to create (match all Figma variants)
+- **Stories plan** — which stories to create (match all Figma variants); include a responsive-case story for any breakpoint-aware input
 
 If a new dependency is needed, stop and ask the user for permission.
 
diff --git a/setup-jest.ts b/setup-jest.ts
index ce207d677..2b9e346ca 100644
--- a/setup-jest.ts
+++ b/setup-jest.ts
@@ -2,6 +2,13 @@ import { setupZoneTestEnv } from "jest-preset-angular/setup-env/zone";
 
 setupZoneTestEnv();
 
+// Mock ResizeObserver which is not implemented in jsdom
+global.ResizeObserver = class {
+  observe = jest.fn();
+  unobserve = jest.fn();
+  disconnect = jest.fn();
+} as unknown as typeof ResizeObserver;
+
 // Mock scrollIntoView which is not implemented in jsdom
 Element.prototype.scrollIntoView = jest.fn();
 
diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md
index 6a73dfb01..cf606947d 100644
--- a/skills/tedi-angular/references/components.md
+++ b/skills/tedi-angular/references/components.md
@@ -363,6 +363,72 @@ statusControl = new FormControl(null);
 
 ```
 
+### TimeField
+**Selector:** `tedi-time-field`
+**Model:** `value: string | null` — `HH:mm`
+**Inputs:**
+- `inputId: string` (required) — unique ID for label association
+- `placeholder: string`
+- `disabled: boolean = false`
+- `invalid: boolean = false` — manually mark the field invalid (combines with reactive-form validity)
+- `clearable: boolean = true`
+- `pickerVariant: TimeFieldPickerVariant = "scroll"` — `"scroll" | "slots" | "dropdown" | "none"`. `"none"` renders just the input — typed input is still normalized on blur (e.g. `9` → `09:00`, `930` → `09:30`)
+- `useNativePicker: TimeFieldUseNativePicker = false` — `true` always uses the OS time picker (``), `false` never, breakpoint name (`"sm" | "md" | "lg" | "xl"`) means native below that breakpoint. When resolved to `true`, overrides `pickerVariant` and `modal`
+- `pickerTrigger: TimeFieldPickerTrigger = "button"` — `"button"` opens via the icon, `"input"` also opens when the input is clicked
+- `closeOnSelect: boolean = false` — close the popover/modal as soon as a value is picked
+- `timeSlots: string[] = []` — `HH:mm` strings for `"slots"` and `"dropdown"` variants
+- `columns: number = 3` — grid columns for the `"slots"` variant
+- `showSlotIndicator: boolean = false` — show the radio indicator dot on each card in the `"slots"` variant
+- `minuteStep: number = 1` — minute increment for the `"scroll"` variant
+- `modal: TimeFieldModal = "md"` — open the picker in a modal: `true` always, `false` never, breakpoint name (`"sm" | "md" | "lg" | "xl"`) means modal below that breakpoint
+- `fullscreen: TimeFieldFullscreen = false` — make the modal fullscreen: `true` always, `false` never, breakpoint name means fullscreen below that breakpoint. Only applies when the picker opens as a modal
+
+Sizing and validation styling come from the wrapping `tedi-form-field` — set them there, not on `tedi-time-field`. Free-typed values are normalized on blur (digits-only → `HH:mm`); invalid input reverts to the previous value.
+
+```html
+
+  
+  
+
+
+
+
+
+
+
+```
+
+### TimePicker
+**Selector:** `tedi-time-picker`
+**Model:** `value: string | null` — `HH:mm`
+**Inputs:**
+- `variant: TimePickerVariant = "scroll"` — `"scroll" | "slots" | "dropdown"`
+- `timeSlots: string[] = []` — predefined `HH:mm` strings for `"slots"` and `"dropdown"`
+- `columns: number = 3` — grid columns for the `"slots"` variant
+- `showSlotIndicator: boolean = false` — show the radio indicator dot on each card in the `"slots"` variant
+- `minuteStep: number = 1` — minute increment for the `"scroll"` variant
+- `disabled: boolean = false`
+- `border: boolean = false` — render with a surrounding border, useful when embedded inline (not in a popover/modal)
+- `trapFocus: boolean = false` — trap Tab inside the picker (used when embedded in a popover/modal). `scroll` cycles between hour/minute columns; `slots`/`dropdown` emit `closeRequested`
+
+**Outputs:**
+- `closeRequested: void` — emitted when the picker requests to be closed (Tab while `trapFocus` is `true`)
+
+**Keyboard:** `scroll` columns respond to `ArrowUp`/`ArrowDown`, `Home`/`End`, `PageUp`/`PageDown` (jump 5); `Enter`/`Space` on the hour column moves focus to minutes. `dropdown` items respond to `ArrowUp`/`ArrowDown`, `Home`/`End`, `Enter`/`Space`.
+
+Standalone time picker. Most consumers should use `tedi-time-field` instead — it bundles the picker, an input, and the popover/modal trigger logic.
+
+```html
+
+
+
+
+```
+
 ### Select
 **Selector:** `tedi-select`
 **Inputs:**
@@ -722,9 +788,13 @@ openModal() {
     width: 'md',                    // 'xs' | 'sm' | 'md' | 'lg' | 'xl' | custom CSS value
     size: 'default',                // 'default' | 'small'
     position: 'center',             // 'center' | 'top' | 'left' | 'right'
-    closeOnBackdropClick: true,
     scrollBehavior: 'content',      // 'content' | 'page'
-    mobileFullscreen: false,
+    closeOnBackdropClick: true,
+    closeOnEscape: true,
+    showClose: true,
+    fullscreen: false,              // true | 'sm' | 'md' | 'lg' | 'xl'
+    maxWidth: '60vw',               // optional cap, overrides default 95vw
+    ariaLabel: 'Confirm action',
   });
 
   ref.closed.subscribe(result => console.log(result));
@@ -733,12 +803,17 @@ openModal() {
 
 **ModalConfig inputs:**
 - `data: unknown` — injected via `MODAL_DATA` token
-- `width: ModalWidth = "sm"` — preset (`xs`-`xl`) or custom CSS value (`"80%"`, `"600px"`)
 - `size: ModalSize = "default"` — `"default"` or `"small"`
-- `position: ModalPosition = "center"` — `"center"`, `"top"`, `"left"`, `"right"`
+- `width: ModalWidth = "sm"` — preset (`"xs" | "sm" | "md" | "lg" | "xl"`) or custom CSS value (`"80%"`, `"600px"`)
+- `position: ModalPosition = "center"` — `"center" | "top" | "left" | "right"`
+- `scrollBehavior: ModalScrollBehavior = "content"` — `"content"` scrolls inside the modal, `"page"` scrolls the overlay
 - `closeOnBackdropClick: boolean = true`
-- `scrollBehavior: "content" | "page" = "content"`
-- `mobileFullscreen: boolean = false`
+- `closeOnEscape: boolean = true`
+- `showClose: boolean = true` — show the close button in the header
+- `fullscreen: ModalFullscreen = false` — `true` always fullscreen, `false` never, breakpoint name (`"sm" | "md" | "lg" | "xl"`) means fullscreen below that breakpoint
+- `maxWidth: string` — optional max-width cap (e.g. `"75%"`, `"60vw"`); overrides the default `95vw`
+- `ariaLabel: string` — ARIA label for the dialog
+- `ariaLabelledBy: string` — ID of the element that labels the dialog
 
 **ModalRef methods/properties:**
 - `close(result?: R)` — close with optional result
diff --git a/skills/tedi-angular/references/forms.md b/skills/tedi-angular/references/forms.md
index 5f75c67f4..78e0e239a 100644
--- a/skills/tedi-angular/references/forms.md
+++ b/skills/tedi-angular/references/forms.md
@@ -13,6 +13,8 @@ TEDI form controls implement Angular's `ControlValueAccessor` interface, integra
 | RadioGroupComponent | `tedi-radio-group` | `string \| null` |
 | ToggleComponent | `tedi-toggle` | `boolean` |
 | DatePickerComponent | `tedi-date-picker` | `Date \| null` |
+| TimeFieldComponent | `tedi-time-field` | `string \| null` (HH:mm) |
+| TimePickerComponent | `tedi-time-picker` | `string \| null` (HH:mm) |
 | DropdownComponent | `tedi-dropdown` | `string` |
 | SelectComponent | `tedi-select` | `T \| T[]` |
 
diff --git a/tedi/components/form/filter/filter.component.ts b/tedi/components/form/filter/filter.component.ts
index 43f907c3c..5ce1bf436 100644
--- a/tedi/components/form/filter/filter.component.ts
+++ b/tedi/components/form/filter/filter.component.ts
@@ -56,7 +56,6 @@ export interface FilterOption {
     DropdownItemValueLabelComponent,
     FormFieldComponent,
     TextFieldComponent,
-    FilterContentDirective,
   ],
   templateUrl: "./filter.component.html",
   styleUrl: "./filter.component.scss",
diff --git a/tedi/components/form/form-field/form-field.component.html b/tedi/components/form/form-field/form-field.component.html
index a8f01d420..41e96764e 100644
--- a/tedi/components/form/form-field/form-field.component.html
+++ b/tedi/components/form/form-field/form-field.component.html
@@ -1,7 +1,7 @@
 
 
 
- + @if (showClearButton()) {
diff --git a/tedi/components/form/index.ts b/tedi/components/form/index.ts index 6ab0fe5e9..944011dc3 100644 --- a/tedi/components/form/index.ts +++ b/tedi/components/form/index.ts @@ -12,7 +12,9 @@ export * from "./label/label.component"; export * from "./number-field/number-field.component"; export * from "./select"; export * from "./toggle/toggle.component"; -export * from "./date-picker/date-picker.component"; export * from "./form-field/form-field.component"; +export * from "./form-field/form-field-control"; export * from "./text-field/text-field.component"; export * from "./filter"; +export * from "./time-field"; +export * from "./time-picker"; diff --git a/tedi/components/form/time-field/index.ts b/tedi/components/form/time-field/index.ts new file mode 100644 index 000000000..c9ed9f1b5 --- /dev/null +++ b/tedi/components/form/time-field/index.ts @@ -0,0 +1 @@ +export * from "./time-field.component"; diff --git a/tedi/components/form/time-field/time-field.component.html b/tedi/components/form/time-field/time-field.component.html new file mode 100644 index 000000000..1af426130 --- /dev/null +++ b/tedi/components/form/time-field/time-field.component.html @@ -0,0 +1,109 @@ +@if (hasPicker() && !useMobileModal()) { + + +
+ +
+ + + +
+} @else { +
+ +
+} + + + +
+ @if (showClear()) { + + + } + @if (hasPicker()) { + + } @else if (hasNativePicker()) { + + } @else { + + } +
+
diff --git a/tedi/components/form/time-field/time-field.component.scss b/tedi/components/form/time-field/time-field.component.scss new file mode 100644 index 000000000..cbe36bfc6 --- /dev/null +++ b/tedi/components/form/time-field/time-field.component.scss @@ -0,0 +1,102 @@ +@use "@tedi-design-system/core/bootstrap-utility/breakpoints"; + +.tedi-time-field { + display: flex; + flex: 1; + min-width: 0; + + &__popover { + display: flex; + flex: 1; + min-width: 0; + } + + &__field { + display: flex; + flex: 1; + gap: var(--form-field-inner-spacing); + align-items: center; + min-width: 0; + padding-right: var(--form-field-padding-x-md-default); + + &:focus, + &:focus-visible { + outline: none; + } + } + + &__input { + flex: 1; + min-width: 0; + padding-left: var(--form-field-padding-x-md-default); + font-size: var(--body-regular-size); + color: var(--form-input-text-filled); + background: transparent; + border: 0; + border-radius: var(--form-field-radius); + + &::placeholder { + color: var(--form-input-text-placeholder); + } + + &:disabled { + color: var(--form-input-text-disabled); + cursor: not-allowed; + } + + &::-webkit-calendar-picker-indicator, + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button, + &::-webkit-clear-button, + &::-webkit-list-button { + display: none; + margin: 0; + appearance: none; + } + } + + &__actions { + display: flex; + flex-shrink: 0; + gap: var(--layout-grid-gutters-04); + align-items: center; + align-self: center; + justify-content: center; + } + + &__clear { + flex-shrink: 0; + + &:disabled { + cursor: not-allowed; + } + } + + &__popover-content.tedi-popover-content { + padding: 0; + } + + &__icon { + flex-shrink: 0; + width: var(--button-xs-icon-size) !important; + height: var(--form-field-button-height-sm) !important; + font-size: 1.125rem !important; + border-radius: var(--button-radius-sm) !important; + + &:disabled { + cursor: not-allowed; + } + + &--static { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--form-input-text-placeholder); + } + + @include breakpoints.media-breakpoint-down(sm) { + width: var(--form-field-button-height) !important; + height: var(--form-field-button-height) !important; + } + } +} diff --git a/tedi/components/form/time-field/time-field.component.spec.ts b/tedi/components/form/time-field/time-field.component.spec.ts new file mode 100644 index 000000000..2a7b43937 --- /dev/null +++ b/tedi/components/form/time-field/time-field.component.spec.ts @@ -0,0 +1,882 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Component, signal } from "@angular/core"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { TimeFieldComponent } from "./time-field.component"; +import { FormFieldComponent } from "../form-field/form-field.component"; +import { LabelComponent } from "../label/label.component"; +import { FeedbackTextComponent } from "../feedback-text/feedback-text.component"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; +import { BreakpointService } from "../../../services/breakpoint/breakpoint.service"; +import { ModalService } from "../../overlay/modal/modal.service"; + +describe("TimeFieldComponent", () => { + let fixture: ComponentFixture; + let component: TimeFieldComponent; + let el: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TimeFieldComponent], + providers: [ + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + + fixture = TestBed.createComponent(TimeFieldComponent); + fixture.componentRef.setInput("inputId", "test-id"); + component = fixture.componentInstance; + el = fixture.nativeElement; + fixture.detectChanges(); + }); + + it("should create component", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize with null value", () => { + expect(component.value()).toBeNull(); + }); + + describe("input", () => { + it("should have correct id", () => { + const input = el.querySelector("input"); + expect(input?.id).toBe("test-id"); + }); + + it("should show placeholder when provided", () => { + fixture.componentRef.setInput("placeholder", "hh:mm"); + fixture.detectChanges(); + + const input = el.querySelector("input"); + expect(input?.placeholder).toBe("hh:mm"); + }); + + it("should update inputValue on typing", () => { + const input = el.querySelector("input")!; + input.value = "14:30"; + input.dispatchEvent(new Event("input")); + + expect(component.inputValue()).toBe("14:30"); + }); + }); + + describe("time parsing on blur", () => { + let input: HTMLInputElement; + + beforeEach(() => { + input = el.querySelector("input")!; + }); + + it("should accept valid time HH:mm", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + input.value = "09:30"; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + + expect(component.value()).toBe("09:30"); + expect(onChange).toHaveBeenCalledWith("09:30"); + }); + + it("should accept single-digit hours and zero-pad", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + input.value = "9:05"; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + + expect(component.value()).toBe("09:05"); + expect(onChange).toHaveBeenCalledWith("09:05"); + }); + + it("should accept 00:00", () => { + input.value = "00:00"; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + + expect(component.value()).toBe("00:00"); + }); + + it("should accept 23:59", () => { + input.value = "23:59"; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + + expect(component.value()).toBe("23:59"); + }); + + it("should normalize 4-digit input without delimiter", () => { + input.value = "1155"; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + + expect(component.value()).toBe("11:55"); + expect(component.inputValue()).toBe("11:55"); + }); + + it("should normalize 3-digit input without delimiter", () => { + input.value = "930"; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + + expect(component.value()).toBe("09:30"); + }); + + it("should normalize alternative delimiters", () => { + input.value = "11.55"; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + + expect(component.value()).toBe("11:55"); + }); + + it("should reject 24:00", () => { + component.writeValue("12:00"); + fixture.detectChanges(); + + input.value = "24:00"; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + + expect(component.value()).toBe("12:00"); + }); + + it("should reject invalid text", () => { + component.writeValue("12:00"); + fixture.detectChanges(); + + input.value = "abc"; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + + expect(component.value()).toBe("12:00"); + expect(component.inputValue()).toBe("12:00"); + }); + + it("should set value to null when input is cleared", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + component.writeValue("12:00"); + fixture.detectChanges(); + + input.value = ""; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + + expect(component.value()).toBeNull(); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it("should call onTouched on blur", () => { + const onTouched = jest.fn(); + component.registerOnTouched(onTouched); + + input.dispatchEvent(new Event("blur")); + + expect(onTouched).toHaveBeenCalled(); + }); + }); + + describe("clear button", () => { + it("should not show clear button when value is null", () => { + expect(el.querySelector(".tedi-time-field__clear")).toBeNull(); + }); + + it("should show clear button when value exists", () => { + component.writeValue("14:30"); + fixture.detectChanges(); + + expect(el.querySelector(".tedi-time-field__clear")).toBeTruthy(); + }); + + it("should clear value on click", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + component.writeValue("14:30"); + fixture.detectChanges(); + + const clearBtn = el.querySelector( + ".tedi-time-field__clear", + ) as HTMLButtonElement; + clearBtn.click(); + fixture.detectChanges(); + + expect(component.value()).toBeNull(); + expect(component.inputValue()).toBe(""); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it("should show separator alongside clear button", () => { + component.writeValue("14:30"); + fixture.detectChanges(); + + expect(el.querySelector("tedi-separator")).toBeTruthy(); + }); + + it("should stop click propagation when the clear button is clicked", () => { + component.writeValue("14:30"); + fixture.detectChanges(); + + const clearBtn = el.querySelector( + ".tedi-time-field__clear", + ) as HTMLButtonElement; + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + const stopSpy = jest.spyOn(event, "stopPropagation"); + + clearBtn.dispatchEvent(event); + expect(stopSpy).toHaveBeenCalled(); + expect(component.value()).toBeNull(); + }); + }); + + describe("field-focus delegation", () => { + it("should forward focus from the wrapper to the input", () => { + const wrapper = el.querySelector(".tedi-time-field__field") as HTMLElement; + const input = el.querySelector(".tedi-time-field__input") as HTMLInputElement; + const focusSpy = jest.spyOn(input, "focus"); + + wrapper.dispatchEvent(new FocusEvent("focus", { bubbles: false })); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("should NOT forward focus when a focus event bubbles from a child", () => { + const wrapper = el.querySelector(".tedi-time-field__field") as HTMLElement; + const input = el.querySelector(".tedi-time-field__input") as HTMLInputElement; + const focusSpy = jest.spyOn(input, "focus"); + + // Simulate a focus event whose target is the input (bubbled up to wrapper). + const event = new FocusEvent("focus", { bubbles: true }); + Object.defineProperty(event, "target", { value: input }); + Object.defineProperty(event, "currentTarget", { value: wrapper }); + component.onFieldFocus(event); + + expect(focusSpy).not.toHaveBeenCalled(); + }); + }); + + describe("FormFieldControl", () => { + it("should expose value signal", () => { + component.writeValue("10:30"); + expect(component.value()).toBe("10:30"); + }); + + it("should expose isDisabled as disabled signal", () => { + expect(component.isDisabled()).toBe(false); + component.setDisabledState(true); + expect(component.isDisabled()).toBe(true); + }); + + it("should expose invalid signal driven by the invalid input", () => { + expect(component.invalid()).toBe(false); + fixture.componentRef.setInput("invalid", true); + fixture.detectChanges(); + expect(component.invalid()).toBe(true); + }); + + it("should expose invalid signal driven by setInvalidState", () => { + expect(component.invalid()).toBe(false); + component.setInvalidState(true); + expect(component.invalid()).toBe(true); + }); + + it("should provide clearField method", () => { + component.writeValue("14:30"); + fixture.detectChanges(); + + component.clearField(); + expect(component.value()).toBeNull(); + expect(component.inputValue()).toBe(""); + }); + }); + + describe("native picker", () => { + beforeEach(() => { + fixture.componentRef.setInput("useNativePicker", true); + fixture.detectChanges(); + }); + + it("should render the visible input with type=time", () => { + const input = el.querySelector(".tedi-time-field__input") as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.type).toBe("time"); + }); + + it("should render trigger button without popover", () => { + expect(el.querySelector("tedi-popover")).toBeNull(); + const btn = el.querySelector(".tedi-time-field__icon") as HTMLButtonElement; + expect(btn).toBeTruthy(); + }); + + it("should sync visible input value from component value", () => { + component.writeValue("15:00"); + fixture.detectChanges(); + + const input = el.querySelector(".tedi-time-field__input") as HTMLInputElement; + expect(input.value).toBe("15:00"); + }); + + it("should commit value on blur after typing", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const input = el.querySelector(".tedi-time-field__input") as HTMLInputElement; + input.value = "16:45"; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + + expect(component.value()).toBe("16:45"); + expect(onChange).toHaveBeenCalledWith("16:45"); + }); + + it("should set value to null when input is cleared and blurred", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + component.writeValue("12:00"); + fixture.detectChanges(); + + const input = el.querySelector(".tedi-time-field__input") as HTMLInputElement; + input.value = ""; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + + expect(component.value()).toBeNull(); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it("should show the clear button immediately after a value is picked (no blur required)", () => { + expect(el.querySelector(".tedi-time-field__clear")).toBeNull(); + + const input = el.querySelector(".tedi-time-field__input") as HTMLInputElement; + input.value = "16:45"; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + + expect(component.value()).toBe("16:45"); + expect(el.querySelector(".tedi-time-field__clear")).toBeTruthy(); + }); + }); + + describe("native picker — breakpoint form", () => { + let isBelowMd: ReturnType>; + let bpFixture: ComponentFixture; + let bpComponent: TimeFieldComponent; + let bpEl: HTMLElement; + + beforeEach(() => { + isBelowMd = signal(false); + const breakpointMock = { + isBelowBreakpoint: (target: string) => + target === "md" ? isBelowMd : signal(false), + isAboveBreakpoint: () => signal(false), + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [TimeFieldComponent], + providers: [ + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + { provide: BreakpointService, useValue: breakpointMock }, + ], + }); + + bpFixture = TestBed.createComponent(TimeFieldComponent); + bpFixture.componentRef.setInput("inputId", "bp-test"); + bpFixture.componentRef.setInput("useNativePicker", "md"); + bpComponent = bpFixture.componentInstance; + bpEl = bpFixture.nativeElement; + bpFixture.detectChanges(); + }); + + it("should use the native picker when current viewport is below the breakpoint", () => { + isBelowMd.set(true); + bpFixture.detectChanges(); + + expect(bpComponent.useNativePickerResolved()).toBe(true); + const input = bpEl.querySelector(".tedi-time-field__input") as HTMLInputElement; + expect(input.type).toBe("time"); + expect(bpEl.querySelector("tedi-popover")).toBeNull(); + }); + + it("should use the custom picker when current viewport is at or above the breakpoint", () => { + isBelowMd.set(false); + bpFixture.detectChanges(); + + expect(bpComponent.useNativePickerResolved()).toBe(false); + const input = bpEl.querySelector(".tedi-time-field__input") as HTMLInputElement; + expect(input.type).toBe("text"); + expect(bpEl.querySelector("tedi-popover")).toBeTruthy(); + }); + + it("should react to viewport changes", () => { + isBelowMd.set(false); + bpFixture.detectChanges(); + expect(bpComponent.useNativePickerResolved()).toBe(false); + + isBelowMd.set(true); + bpFixture.detectChanges(); + expect(bpComponent.useNativePickerResolved()).toBe(true); + }); + }); + + describe("modal breakpoint switch", () => { + let isBelowMd: ReturnType>; + let mFixture: ComponentFixture; + let mComponent: TimeFieldComponent; + let mEl: HTMLElement; + + beforeEach(() => { + isBelowMd = signal(false); + const breakpointMock = { + isBelowBreakpoint: (target: string) => + target === "md" ? isBelowMd : signal(false), + isAboveBreakpoint: () => signal(false), + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [TimeFieldComponent], + providers: [ + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + { provide: BreakpointService, useValue: breakpointMock }, + ], + }); + + mFixture = TestBed.createComponent(TimeFieldComponent); + mFixture.componentRef.setInput("inputId", "modal-test"); + mFixture.componentRef.setInput("modal", "md"); + mComponent = mFixture.componentInstance; + mEl = mFixture.nativeElement; + mFixture.detectChanges(); + }); + + it("should render the popover branch when above the modal breakpoint", () => { + isBelowMd.set(false); + mFixture.detectChanges(); + + expect(mComponent.useMobileModal()).toBe(false); + expect(mEl.querySelector("tedi-popover")).toBeTruthy(); + }); + + it("should drop the popover branch when below the modal breakpoint", () => { + isBelowMd.set(true); + mFixture.detectChanges(); + + expect(mComponent.useMobileModal()).toBe(true); + expect(mEl.querySelector("tedi-popover")).toBeNull(); + }); + + it("should not use the modal when pickerVariant=none even if below the breakpoint", () => { + mFixture.componentRef.setInput("pickerVariant", "none"); + isBelowMd.set(true); + mFixture.detectChanges(); + + expect(mComponent.useMobileModal()).toBe(false); + }); + }); + + describe("openMobileModal lifecycle", () => { + let modalServiceMock: { open: jest.Mock }; + let modalRefMock: { closed: { subscribe: jest.Mock } }; + let closedCallback: ((result: string | null | undefined) => void) | null; + let mFixture: ComponentFixture; + let mComponent: TimeFieldComponent; + let mEl: HTMLElement; + + beforeEach(() => { + closedCallback = null; + modalRefMock = { + closed: { + subscribe: jest.fn((fn) => { + closedCallback = fn; + return { unsubscribe: jest.fn() }; + }), + }, + }; + modalServiceMock = { + open: jest.fn().mockReturnValue(modalRefMock), + }; + const breakpointMock = { + isBelowBreakpoint: () => signal(true), + isAboveBreakpoint: () => signal(false), + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [TimeFieldComponent], + providers: [ + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + { provide: BreakpointService, useValue: breakpointMock }, + { provide: ModalService, useValue: modalServiceMock }, + ], + }); + + mFixture = TestBed.createComponent(TimeFieldComponent); + mFixture.componentRef.setInput("inputId", "modal-life"); + mFixture.componentRef.setInput("modal", true); + mComponent = mFixture.componentInstance; + mEl = mFixture.nativeElement; + mFixture.detectChanges(); + }); + + it("should open the modal with the current picker config", () => { + mFixture.componentRef.setInput("timeSlots", ["09:00", "09:30"]); + mFixture.componentRef.setInput("columns", 2); + mFixture.componentRef.setInput("minuteStep", 5); + mComponent.writeValue("10:15"); + mFixture.detectChanges(); + + mComponent.openPicker(); + + expect(modalServiceMock.open).toHaveBeenCalledTimes(1); + const args = modalServiceMock.open.mock.calls[0]; + expect(args[1].data).toEqual( + expect.objectContaining({ + value: "10:15", + variant: "scroll", + timeSlots: ["09:00", "09:30"], + columns: 2, + minuteStep: 5, + }), + ); + }); + + it("should commit the result and refocus the input when the modal closes with a value", () => { + const onChange = jest.fn(); + mComponent.registerOnChange(onChange); + const inputEl = mEl.querySelector("input") as HTMLInputElement; + const focusSpy = jest.spyOn(inputEl, "focus"); + + mComponent.openPicker(); + expect(closedCallback).toBeTruthy(); + closedCallback!("14:00"); + mFixture.detectChanges(); + + expect(mComponent.value()).toBe("14:00"); + expect(onChange).toHaveBeenCalledWith("14:00"); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("should NOT change value when the modal closes with undefined (cancel)", () => { + const onChange = jest.fn(); + mComponent.registerOnChange(onChange); + mComponent.writeValue("08:00"); + mFixture.detectChanges(); + + mComponent.openPicker(); + closedCallback!(undefined); + mFixture.detectChanges(); + + expect(mComponent.value()).toBe("08:00"); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("should call onTouched on close regardless of result", () => { + const onTouched = jest.fn(); + mComponent.registerOnTouched(onTouched); + + mComponent.openPicker(); + closedCallback!(undefined); + + expect(onTouched).toHaveBeenCalled(); + }); + + it("should forward the fullscreen input to the modal config", () => { + mFixture.componentRef.setInput("fullscreen", "md"); + mFixture.detectChanges(); + + mComponent.openPicker(); + + const args = modalServiceMock.open.mock.calls[0]; + expect(args[1].fullscreen).toBe("md"); + }); + }); + + describe("picker trigger", () => { + it("should open popover when input is clicked with pickerTrigger=input", () => { + fixture.componentRef.setInput("pickerTrigger", "input"); + fixture.detectChanges(); + + const input = el.querySelector(".tedi-time-field__input") as HTMLInputElement; + const popoverSpy = jest.spyOn(component.popover()!, "showPopover"); + + input.click(); + + expect(popoverSpy).toHaveBeenCalled(); + }); + + it("should NOT open popover when input is clicked with pickerTrigger=button", () => { + const input = el.querySelector(".tedi-time-field__input") as HTMLInputElement; + const popoverSpy = jest.spyOn(component.popover()!, "showPopover"); + + input.click(); + + expect(popoverSpy).not.toHaveBeenCalled(); + }); + + it("should mark input as readonly when pickerTrigger=input", () => { + fixture.componentRef.setInput("pickerTrigger", "input"); + fixture.detectChanges(); + + const input = el.querySelector(".tedi-time-field__input") as HTMLInputElement; + expect(input.readOnly).toBe(true); + }); + }); + + describe("picker variant=none", () => { + beforeEach(() => { + fixture.componentRef.setInput("pickerVariant", "none"); + fixture.detectChanges(); + }); + + it("should render the visible input as plain text (no native picker)", () => { + const input = el.querySelector(".tedi-time-field__input") as HTMLInputElement; + expect(input.type).toBe("text"); + }); + + it("should not render any picker affordance", () => { + expect(el.querySelector("tedi-popover")).toBeNull(); + expect(el.querySelector(".tedi-time-field__icon")).toBeTruthy(); + expect( + el.querySelector(".tedi-time-field__icon--static"), + ).toBeTruthy(); + }); + }); + + describe("states", () => { + it("should disable input when disabled", () => { + fixture.componentRef.setInput("disabled", true); + fixture.detectChanges(); + + expect(el.querySelector("input")?.disabled).toBe(true); + }); + + it("should disable icon button when disabled", () => { + fixture.componentRef.setInput("disabled", true); + fixture.detectChanges(); + + const iconBtn = el.querySelector( + ".tedi-time-field__icon", + ) as HTMLButtonElement; + expect(iconBtn.disabled).toBe(true); + }); + }); + + describe("accessibility", () => { + it("should set aria-invalid when setInvalidState(true) is called", () => { + component.setInvalidState(true); + fixture.detectChanges(); + + expect(el.querySelector("input")?.getAttribute("aria-invalid")).toBe( + "true", + ); + }); + + it("should set aria-invalid when the invalid input is true", () => { + fixture.componentRef.setInput("invalid", true); + fixture.detectChanges(); + + expect(el.querySelector("input")?.getAttribute("aria-invalid")).toBe( + "true", + ); + }); + + it("should not set aria-invalid by default", () => { + expect( + el.querySelector("input")?.hasAttribute("aria-invalid"), + ).toBe(false); + }); + + it("should have aria-label on icon button", () => { + const iconBtn = el.querySelector(".tedi-time-field__icon"); + expect(iconBtn?.getAttribute("aria-label")).toBeTruthy(); + }); + }); + + describe("ControlValueAccessor", () => { + it("should set value via writeValue", () => { + component.writeValue("08:15"); + + expect(component.value()).toBe("08:15"); + expect(component.inputValue()).toBe("08:15"); + }); + + it("should handle null writeValue", () => { + component.writeValue(null); + + expect(component.value()).toBeNull(); + expect(component.inputValue()).toBe(""); + }); + + it("should set formDisabled via setDisabledState", () => { + component.setDisabledState(true); + fixture.detectChanges(); + + expect(component.isDisabled()).toBe(true); + expect(el.querySelector("input")?.disabled).toBe(true); + }); + }); +}); + +@Component({ + standalone: true, + imports: [TimeFieldComponent, ReactiveFormsModule], + template: ``, +}) +class TestHostComponent { + control = new FormControl(null); +} + +describe("TimeFieldComponent with ReactiveFormsModule", () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + providers: [ + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should sync FormControl value to component", () => { + host.control.setValue("10:00"); + fixture.detectChanges(); + + const input = fixture.nativeElement.querySelector("input"); + expect(input.value).toBe("10:00"); + }); + + it("should sync component value to FormControl on blur", () => { + const input = fixture.nativeElement.querySelector("input")!; + input.value = "15:45"; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + fixture.detectChanges(); + + expect(host.control.value).toBe("15:45"); + }); + + it("should disable component when FormControl is disabled", () => { + host.control.disable(); + fixture.detectChanges(); + + const input = fixture.nativeElement.querySelector("input"); + expect(input.disabled).toBe(true); + }); +}); + +@Component({ + standalone: true, + imports: [ + TimeFieldComponent, + FormFieldComponent, + LabelComponent, + FeedbackTextComponent, + ReactiveFormsModule, + ], + template: ` + + + + + + `, +}) +class CompositeTestHostComponent { + control = new FormControl(null); +} + +@Component({ + standalone: true, + imports: [TimeFieldComponent, FormFieldComponent, LabelComponent], + template: ` + + + + + `, +}) +class InvalidBindingHostComponent { + invalid = false; +} + +describe("TimeFieldComponent invalid input binding", () => { + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [InvalidBindingHostComponent], + providers: [ + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + fixture = TestBed.createComponent(InvalidBindingHostComponent); + fixture.detectChanges(); + }); + + it("should toggle tedi-form-field--invalid when [invalid] changes", () => { + const ff = fixture.nativeElement.querySelector( + "tedi-form-field", + ) as HTMLElement; + expect(ff.classList.contains("tedi-form-field--invalid")).toBe(false); + + fixture.componentInstance.invalid = true; + fixture.detectChanges(); + expect(ff.classList.contains("tedi-form-field--invalid")).toBe(true); + + fixture.componentInstance.invalid = false; + fixture.detectChanges(); + expect(ff.classList.contains("tedi-form-field--invalid")).toBe(false); + }); +}); + +describe("TimeFieldComponent inside FormFieldComponent", () => { + let fixture: ComponentFixture; + let host: CompositeTestHostComponent; + let el: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CompositeTestHostComponent], + providers: [ + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + + fixture = TestBed.createComponent(CompositeTestHostComponent); + host = fixture.componentInstance; + el = fixture.nativeElement; + fixture.detectChanges(); + }); + + it("should render time-field inside form-field", () => { + expect(el.querySelector("tedi-form-field")).toBeTruthy(); + expect(el.querySelector("tedi-time-field")).toBeTruthy(); + expect(el.querySelector("input")).toBeTruthy(); + }); + + it("should render projected label", () => { + const label = el.querySelector("[tedi-label]"); + expect(label).toBeTruthy(); + expect(label?.textContent?.trim()).toBe("Time"); + }); + + it("should render projected feedback text", () => { + expect(el.querySelector("tedi-feedback-text")).toBeTruthy(); + }); + + it("should sync FormControl value", () => { + host.control.setValue("09:00"); + fixture.detectChanges(); + + const input = el.querySelector("input"); + expect(input?.value).toBe("09:00"); + }); +}); diff --git a/tedi/components/form/time-field/time-field.component.ts b/tedi/components/form/time-field/time-field.component.ts new file mode 100644 index 000000000..aa463323d --- /dev/null +++ b/tedi/components/form/time-field/time-field.component.ts @@ -0,0 +1,418 @@ +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + forwardRef, + inject, + Injector, + input, + model, + signal, + ViewEncapsulation, + viewChild, + effect, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { NgTemplateOutlet } from "@angular/common"; +import { ButtonComponent } from "../../buttons/button/button.component"; +import { ClosingButtonComponent } from "../../buttons/closing-button/closing-button.component"; +import { IconComponent } from "../../base/icon/icon.component"; +import { SeparatorComponent } from "../../helpers/separator/separator.component"; +import { PopoverComponent } from "../../overlay/popover/popover.component"; +import { PopoverContentComponent } from "../../overlay/popover/popover-content/popover-content.component"; +import { PopoverTriggerDirective } from "../../overlay/popover/popover-trigger/popover-trigger.directive"; +import { + TimePickerComponent, + TimePickerVariant, +} from "../time-picker/time-picker.component"; +import { TediTranslationPipe } from "../../../services/translation/translation.pipe"; +import { BreakpointService } from "../../../services/breakpoint/breakpoint.service"; +import { ModalService } from "../../overlay/modal/modal.service"; +import { ModalFullscreen } from "../../overlay/modal/modal.types"; +import { + FormFieldControl, + TEDI_FORM_FIELD_CONTROL, +} from "../form-field/form-field-control"; +import { + TimePickerModalComponent, + TimePickerModalData, +} from "./time-picker-modal.component"; +import { normalizeTime } from "../../../utils/time.util"; + +export type TimeFieldPickerVariant = TimePickerVariant | "none"; +export type TimeFieldPickerTrigger = "button" | "input"; +export type TimeFieldModal = boolean | "sm" | "md" | "lg" | "xl"; +export type TimeFieldFullscreen = ModalFullscreen; +export type TimeFieldUseNativePicker = boolean | "sm" | "md" | "lg" | "xl"; + +@Component({ + selector: "tedi-time-field", + standalone: true, + templateUrl: "./time-field.component.html", + styleUrl: "./time-field.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ + NgTemplateOutlet, + ButtonComponent, + ClosingButtonComponent, + SeparatorComponent, + IconComponent, + PopoverComponent, + PopoverContentComponent, + PopoverTriggerDirective, + TimePickerComponent, + TediTranslationPipe, + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeFieldComponent), + multi: true, + }, + { + provide: TEDI_FORM_FIELD_CONTROL, + useExisting: forwardRef(() => TimeFieldComponent), + }, + ], + host: { + class: "tedi-time-field", + }, +}) +export class TimeFieldComponent + implements ControlValueAccessor, FormFieldControl +{ + /** Unique ID for label association and accessibility. */ + readonly inputId = input.required(); + /** Selected time in `HH:mm` format. Two-way bindable. */ + readonly value = model(null); + /** Placeholder shown when the input is empty. */ + readonly placeholder = input(); + /** + * Manually mark the field as invalid. Sets `aria-invalid` on the input and triggers + * the form-field's invalid styling. Combines with reactive-form validity reported + * via `setInvalidState`. + */ + // eslint-disable-next-line @angular-eslint/no-input-rename + protected readonly invalidInput = input(false, { alias: "invalid" }); + /** Disables interaction. Combines with the form-control disabled state. */ + readonly disabled = input(false); + /** Show a clear button when the field has a value. */ + readonly clearable = input(true); + /** Picker variant. `none` renders just the input with no picker UI — typed input is still normalized on blur. */ + readonly pickerVariant = input("scroll"); + /** + * Use the OS native time picker instead of the custom one. `true` always, `false` never, breakpoint name → native below that breakpoint (custom from that breakpoint up). + * When resolved to `true`, overrides `pickerVariant` and `modal` — the input renders as `type="time"`. + */ + readonly useNativePicker = input(false); + /** What opens the picker: only the icon (`button`) or also clicking the input (`input`). */ + readonly pickerTrigger = input("button"); + /** Close the popover/modal as soon as the user picks a value. */ + readonly closeOnSelect = input(false); + /** Predefined `HH:mm` strings for the `slots` and `dropdown` variants. */ + readonly timeSlots = input([]); + /** Grid columns for the `slots` variant. */ + readonly columns = input(3); + /** Show the radio indicator dot on each card in the `slots` variant. */ + readonly showSlotIndicator = input(false); + /** Minute step for the `scroll` variant — e.g. `5` renders `00, 05, 10…`. */ + readonly minuteStep = input(1); + /** Open the picker in a modal: `true` always, `false` never, breakpoint name → modal below that breakpoint. */ + readonly modal = input("md"); + /** Make the mobile modal fullscreen: `true` always, `false` never, breakpoint name → fullscreen below that breakpoint. Only applies when the picker actually opens as a modal. */ + readonly fullscreen = input(false); + + private readonly breakpointService = inject(BreakpointService); + private readonly modalService = inject(ModalService); + private readonly injector = inject(Injector); + + readonly inputElement = + viewChild.required>("inputElement"); + readonly fieldEl = viewChild>("fieldEl"); + readonly popover = viewChild("popover"); + readonly timePicker = viewChild("timePicker"); + + readonly dropdownMinWidth = signal(null); + + readonly inputValue = signal(""); + + private formDisabled = signal(false); + private formInvalid = signal(false); + private onChange: (value: string | null) => void = () => {}; + private onTouched: () => void = () => {}; + + readonly isDisabled = computed(() => this.disabled() || this.formDisabled()); + readonly invalid = computed( + () => this.invalidInput() || this.formInvalid(), + ); + readonly hasValue = computed( + () => this.value() !== null && this.value() !== "", + ); + readonly showClear = computed(() => this.hasValue() && this.clearable()); + readonly useNativePickerResolved = computed(() => { + const v = this.useNativePicker(); + return typeof v === "boolean" + ? v + : this.breakpointService.isBelowBreakpoint(v)(); + }); + readonly hasPicker = computed( + () => this.pickerVariant() !== "none" && !this.useNativePickerResolved(), + ); + readonly hasNativePicker = computed(() => this.useNativePickerResolved()); + readonly customPickerVariant = computed( + () => this.pickerVariant() as TimePickerVariant, + ); + readonly inputType = computed(() => + this.useNativePickerResolved() ? "time" : "text", + ); + readonly popoverPosition = computed(() => + this.pickerTrigger() === "input" + ? ("bottom-start" as const) + : ("bottom-end" as const), + ); + readonly inputIsTrigger = computed( + () => + this.pickerTrigger() === "input" && + (this.hasPicker() || this.useMobileModal()), + ); + readonly useMobileModal = computed(() => { + if (!this.hasPicker()) return false; + const modal = this.modal(); + return typeof modal === "boolean" + ? modal + : this.breakpointService.isBelowBreakpoint(modal)(); + }); + + constructor() { + effect(() => { + const val = this.value(); + this.inputValue.set(val ?? ""); + }); + } + + writeValue(value: string | null): void { + // The constructor effect also mirrors `value` into `inputValue`, but we sync it + // here too so synchronous reads (e.g. tests, CVA wiring) see the new value + // without waiting for the next change-detection tick. + this.value.set(value); + this.inputValue.set(value ?? ""); + } + + registerOnChange(fn: (value: string | null) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(disabled: boolean): void { + this.formDisabled.set(disabled); + } + + setInvalidState(isInvalid: boolean): void { + this.formInvalid.set(isInvalid); + } + + handleInput(event: Event) { + const value = (event.target as HTMLInputElement).value; + this.inputValue.set(value); + + if (this.useNativePickerResolved()) { + const next = value === "" ? null : value; + if (next !== this.value()) { + this.value.set(next); + this.onChange(next); + } + } + } + + handleBlur() { + this.onTouched(); + + const raw = this.inputValue(); + const normalized = normalizeTime(raw); + + if (normalized === "") { + if (this.value() !== null) { + this.value.set(null); + this.onChange(null); + } + return; + } + + if (normalized !== null) { + if (normalized !== this.value()) { + this.value.set(normalized); + this.onChange(normalized); + } + this.inputValue.set(normalized); + } else { + this.inputValue.set(this.value() ?? ""); + } + } + + clearInput() { + this.value.set(null); + this.inputValue.set(""); + this.onChange(null); + this.inputElement().nativeElement.focus(); + } + + clearField() { + this.clearInput(); + } + + openNativePicker() { + const input = this.inputElement().nativeElement; + if (typeof input.showPicker === "function") { + try { + input.showPicker(); + return; + } catch { + /* showPicker may throw outside a user gesture — fall through */ + } + } + input.focus(); + } + + onInputClick(event: MouseEvent) { + // When the input isn't acting as the picker trigger, stop the click from + // bubbling to the wrapper's popover-trigger directive (which would otherwise + // open the picker on every click into the input). + if (this.isDisabled() || !this.inputIsTrigger()) { + event.stopPropagation(); + return; + } + if (this.useMobileModal()) { + this.openPicker(); + } + } + + onClearClick(event: MouseEvent) { + event.stopPropagation(); + this.clearInput(); + } + + // Forwards focus from the wrapper (popover-trigger) to the input. The popover's + // `hidePopover(true)` lands focus on the wrapper element; we delegate so the user + // gets a single visible focus indicator on the input instead of two stacked rings. + // The guard ignores bubbled focus events from inner elements (input, buttons) so + // we don't loop when focus actually arrives at the input. + onFieldFocus(event: FocusEvent) { + if (event.target !== event.currentTarget) return; + this.inputElement().nativeElement.focus({ preventScroll: true }); + } + + openPicker() { + if (this.isDisabled()) return; + if (!this.hasPicker()) return; + + if (this.useMobileModal()) { + this.openMobileModal(); + return; + } + + this.popover()?.showPopover(); + } + + // Icon-button click handler. In popover mode the wrapper's `tedi-popover-trigger` + // opens the popover via click bubbling, so we just run scroll/focus side effects; + // in mobile-modal mode there's no wrapper directive, so we open the modal explicitly. + triggerPicker() { + this.onPickerOpen(); + if (this.useMobileModal()) { + this.openPicker(); + } + } + + private openMobileModal() { + const data: TimePickerModalData = { + value: this.value(), + variant: this.customPickerVariant(), + timeSlots: this.timeSlots(), + columns: this.columns(), + showSlotIndicator: this.showSlotIndicator(), + minuteStep: this.minuteStep(), + }; + + const ref = this.modalService.open( + TimePickerModalComponent, + { + data, + size: "small", + width: "sm", + position: "center", + fullscreen: this.fullscreen(), + maxWidth: "var(--tedi-containers-03)", + }, + ); + + ref.closed.subscribe((result) => { + this.onTouched(); + if (result === undefined) return; + if (result !== this.value()) { + this.value.set(result); + this.inputValue.set(result ?? ""); + this.onChange(result); + } + this.inputElement().nativeElement.focus(); + }); + } + + onPickerOpen() { + // Dropdown variant: size its min-width to the field wrapper so it spans + // the full input area instead of shrinking to fit the time strings. + // The popover content is positioned relative to the wrapper but sized + // by its own content, so this min-width is what visually fills the gap. + if (this.pickerVariant() === "dropdown") { + const w = this.fieldEl()?.nativeElement.offsetWidth ?? null; + this.dropdownMinWidth.set(w); + } else { + this.dropdownMinWidth.set(null); + } + + // The popover renders on the next change-detection tick, so we wait for two + // renders: the first lays out the picker (needed for scrollToSelected to + // measure item heights); the second lets the scroll position settle before + // we move focus into the picker. + afterNextRender( + () => { + this.timePicker()?.scrollToSelected(); + afterNextRender( + () => this.timePicker()?.focusActiveItem(), + { injector: this.injector }, + ); + }, + { injector: this.injector }, + ); + } + + onPickerValueChange(newValue: string | null) { + if (newValue !== this.value()) { + this.value.set(newValue); + this.inputValue.set(newValue ?? ""); + this.onTouched(); + this.onChange(newValue); + } + + if (this.closeOnSelect()) { + this.closePopover(); + } + } + + // Closes the picker popover and lands focus back on the input. We always pass + // `focusTrigger=false` to the popover (its built-in path would land focus on the + // wrapper, which (focus) would then bounce into the input — same destination, + // one extra hop). `notifyTouched` is true for explicit user dismissals + // (Tab/Escape from the picker, value selected with closeOnSelect) so + // ControlValueAccessor consumers see a touch event. + closePopover(notifyTouched = true) { + this.popover()?.hidePopover(false); + this.inputElement().nativeElement.focus({ preventScroll: true }); + if (notifyTouched) this.onTouched(); + } +} diff --git a/tedi/components/form/time-field/time-field.stories.ts b/tedi/components/form/time-field/time-field.stories.ts new file mode 100644 index 000000000..51419d01c --- /dev/null +++ b/tedi/components/form/time-field/time-field.stories.ts @@ -0,0 +1,692 @@ +import { + Meta, + StoryObj, + moduleMetadata, +} from "@storybook/angular"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { TimeFieldComponent } from "./time-field.component"; +import { FormFieldComponent } from "../form-field/form-field.component"; +import { LabelComponent } from "../label/label.component"; +import { FeedbackTextComponent } from "../feedback-text/feedback-text.component"; +import { RowComponent } from "../../helpers/grid/row/row.component"; +import { ColComponent } from "../../helpers/grid/col/col.component"; +import { AlertComponent } from "../../notifications/alert/alert.component"; +import { TextComponent } from "../../base/text/text.component"; + +const PSEUDO_STATE = ["Default", "Hover", "Active", "Disabled", "Focus"]; + +/** + * Figma ↗
+ * Zeroheight ↗ + */ + +export default { + title: "TEDI-Ready/Components/Form/TimeField", + component: TimeFieldComponent, + decorators: [ + moduleMetadata({ + imports: [ + FormFieldComponent, + LabelComponent, + FeedbackTextComponent, + RowComponent, + ColComponent, + ReactiveFormsModule, + AlertComponent, + TextComponent, + ], + }), + ], + parameters: { + status: { + type: ["breakpointSupport"], + }, + }, + argTypes: { + inputId: { + description: "Unique ID for label association and accessibility.", + control: { type: "text" }, + table: { + category: "inputs", + type: { summary: "string" }, + }, + }, + value: { + description: "Selected time in HH:mm format. Two-way bindable.", + control: { type: "text" }, + table: { + category: "inputs", + type: { summary: "string | null" }, + defaultValue: { summary: "null" }, + }, + }, + placeholder: { + description: "Placeholder shown when the input is empty.", + control: { type: "text" }, + table: { + category: "inputs", + type: { summary: "string" }, + }, + }, + invalid: { + description: + "Manually mark the field as invalid. Sets `aria-invalid` on the input and triggers the form-field's invalid styling. Combines with the form-control validity state from reactive forms.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + disabled: { + description: "Disables interaction. Combines with the form-control disabled state.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + clearable: { + description: "Show a clear button when the field has a value.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + pickerVariant: { + description: + "Picker variant. `none` renders just the input with no picker UI — typed input is still normalized on blur.", + control: { type: "radio" }, + options: ["scroll", "slots", "dropdown", "none"], + table: { + category: "inputs", + type: { + summary: "TimeFieldPickerVariant", + detail: "scroll \nslots \ndropdown \nnone", + }, + defaultValue: { summary: "scroll" }, + }, + }, + useNativePicker: { + description: + "Use the OS native time picker: `true` always, `false` never, breakpoint name (`sm | md | lg | xl`) means native below that breakpoint. When resolved to `true`, overrides `pickerVariant` and `modal`.", + control: { type: "select" }, + options: [true, false, "sm", "md", "lg", "xl"], + table: { + category: "inputs", + type: { + summary: "TimeFieldUseNativePicker", + detail: "boolean \nsm \nmd \nlg \nxl", + }, + defaultValue: { summary: "false" }, + }, + }, + pickerTrigger: { + description: + "What opens the picker: only the icon (`button`) or also clicking the input (`input`).", + control: { type: "radio" }, + options: ["button", "input"], + table: { + category: "inputs", + type: { summary: "TimeFieldPickerTrigger", detail: "button \ninput" }, + defaultValue: { summary: "button" }, + }, + }, + closeOnSelect: { + description: "Close the popover/modal as soon as the user picks a value.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + timeSlots: { + description: "Predefined HH:mm strings for the slots and dropdown variants.", + control: { type: "object" }, + table: { + category: "inputs", + type: { summary: "string[]" }, + defaultValue: { summary: "[]" }, + }, + }, + columns: { + description: "Grid columns for the slots variant.", + control: { type: "number" }, + table: { + category: "inputs", + type: { summary: "number" }, + defaultValue: { summary: "3" }, + }, + }, + minuteStep: { + description: "Minute step for the scroll variant — e.g. 5 renders 00, 05, 10…", + control: { type: "number" }, + table: { + category: "inputs", + type: { summary: "number" }, + defaultValue: { summary: "1" }, + }, + }, + modal: { + description: + "Open the picker in a modal: `true` always, `false` never, breakpoint name (`sm | md | lg | xl`) means modal below that breakpoint.", + control: { type: "select" }, + options: [true, false, "sm", "md", "lg", "xl"], + table: { + category: "inputs", + type: { + summary: "TimeFieldModal", + detail: "boolean \nsm \nmd \nlg \nxl", + }, + defaultValue: { summary: "md" }, + }, + }, + fullscreen: { + description: + "Render the picker modal fullscreen: `true` always, `false` never, breakpoint name (`sm | md | lg | xl`) means fullscreen below that breakpoint. Only applies when the picker opens as a modal.", + control: { type: "select" }, + options: [true, false, "sm", "md", "lg", "xl"], + table: { + category: "inputs", + type: { + summary: "TimeFieldFullscreen", + detail: "boolean \nsm \nmd \nlg \nxl", + }, + defaultValue: { summary: "false" }, + }, + }, + }, +} as Meta; + +export const Default: StoryObj = { + args: { + inputId: "example-id", + placeholder: "hh:mm", + invalid: false, + disabled: false, + clearable: true, + pickerVariant: "scroll", + useNativePicker: false, + pickerTrigger: "button", + closeOnSelect: false, + timeSlots: ["09:00", "09:30", "10:00", "10:30", "11:00", "11:30"], + columns: 3, + minuteStep: 1, + modal: "md", + fullscreen: false, + }, + render: (args) => ({ + props: args, + template: ` + + + + + + + + + `, + }), +}; + +export const Sizes: StoryObj = { + render: () => ({ + template: ` + + + Default + + + + + + + Small + + + + + + + `, + }), + parameters: { + docs: { + description: { + story: + "Field size is controlled by the surrounding `` — the `tedi-time-field` itself has no `size` input.", + }, + }, + }, +}; + +export const States: StoryObj = { + parameters: { + pseudo: { + hover: "#Hover", + active: "#Active", + focusVisible: "#Focus", + }, + }, + render: () => ({ + props: { PSEUDO_STATE }, + template: ` + + @for (state of PSEUDO_STATE; track state) { + + +

{{ state }}

+
+ + + + + + +
+ } + + +

Error

+
+ + + + + + + +
+ + +

Success

+
+ + + + + + + +
+
+ `, + }), +}; + +export const FieldOptions: StoryObj = { + render: () => ({ + template: ` + + + + + + + + + + + + + + + + `, + }), +}; + +export const ValueType: StoryObj = { + render: () => ({ + template: ` + + + + + + + + + + + + + + + + + + + `, + }), + parameters: { + docs: { + description: { + story: "Default empty field, with placeholder, and with a pre-filled value.", + }, + }, + }, +}; + +export const PickerTrigger: StoryObj = { + render: () => ({ + template: ` + + + + +

Clock button is clickable

+ + + + +
+
+
+ + + +

Input is clickable

+ + + + +
+
+
+
+ `, + }), +}; + +export const WithScrollPicker: StoryObj = { + render: () => ({ + template: ` + + +

Button trigger

+ + + + +
+ +

Input trigger

+ + + + +
+
+ `, + }), +}; + +export const WithSlotsPicker: StoryObj = { + render: () => ({ + props: { + slots: ["09:30", "10:00", "11:30", "15:30", "18:30", "20:30"], + }, + template: ` + + +

Button trigger

+ + + + +
+ +

Input trigger (recommended for slots)

+ + + + +
+
+ `, + }), + parameters: { + docs: { + description: { + story: + "When the picker offers a fixed set of choices (`slots` / `dropdown`), prefer `pickerTrigger=\"input\"` so the user is signalled the input is not free-form. Button-trigger is shown for completeness.", + }, + }, + }, +}; + +export const WithDropdownPicker: StoryObj = { + render: () => ({ + props: { + slots: ["12:30", "13:00", "13:30", "14:00", "14:30"], + }, + template: ` + + +

Button trigger

+ + + + +
+ +

Input trigger (recommended for dropdown)

+ + + + +
+
+ `, + }), +}; + +export const WithCustomMinuteStep: StoryObj = { + render: () => ({ + template: ` + + + + + + + + + `, + }), + parameters: { + docs: { + description: { + story: + "`[minuteStep]=\"15\"` renders the minute wheel as `00, 15, 30, 45`. Any divisor of 60 works (`1`, `5`, `10`, `15`, `20`, `30`).", + }, + }, + }, +}; + +export const NativePicker: StoryObj = { + render: () => ({ + template: ` + + +

Always native (useNativePicker=true)

+ + + + +
+ +

Responsive (useNativePicker=md)

+ + + + +
+
+ `, + }), + parameters: { + docs: { + description: { + story: + "Use the browser's built-in `` UI instead of the custom popover. `[useNativePicker]=\"true\"` forces it everywhere; a breakpoint name like `useNativePicker=\"md\"` uses the native picker below that breakpoint and the custom variant from it upward — handy for native UX on phones and the custom variant on desktop.", + }, + }, + }, +}; + +export const WithoutPicker: StoryObj = { + render: () => ({ + template: ` + + + + + + + + + `, + }), + parameters: { + docs: { + description: { + story: + "When you only need a typed time entry without any picker UI, set `pickerVariant=\"none\"`. The input stays a plain text field, so the same blur-time normalization as the `InputFormatting` story applies. Use `useNativePicker` if you want the browser's `type=\"time\"` UI instead.", + }, + }, + }, +}; + +export const MobileModal: StoryObj = { + render: () => ({ + template: ` + + +

Centered modal

+ + + + +
+ +

Fullscreen modal (fullscreen=md)

+ + + + +
+
+ `, + }), + parameters: { + viewport: { defaultViewport: "mobile1" }, + docs: { + description: { + story: + "Below the `md` breakpoint, the picker opens in a modal with explicit Cancel/Confirm buttons instead of a popover. By default the modal is centered; add `fullscreen=\"md\"` to make it fullscreen on the same breakpoint — useful on small phones where vertical space is tight. Both `modal` and `fullscreen` accept the same union (`true | false | sm | md | lg | xl`).", + }, + }, + }, +}; + +export const InputFormatting: StoryObj = { + render: () => ({ + template: ` + + + + + + + + + + `, + }), + parameters: { + docs: { + description: { + story: ` +On blur, typed input is normalized to the canonical \`HH:mm\` form. + +| Input | Normalized | Notes | +| ---------------- | ---------- | ----------------------------------------------- | +| \`9:5\` | \`09:05\` | Single-digit hour or minute is zero-padded | +| \`1155\` | \`11:55\` | 4 digits → split as \`HH\` + \`mm\` | +| \`930\` | \`09:30\` | 3 digits → split as \`H\` + \`mm\` | +| \`11.55\`, \`11-55\`, \`11 55\` | \`11:55\` | Any non-digit is treated as the separator | +`, + }, + }, + }, +}; + +export const WithReactiveForms: StoryObj = { + render: () => { + const control = new FormControl("12:00"); + + return { + props: { control }, + template: ` + + + + + + + + + + + + + +
{{ {
+  value: control.value,
+  touched: control.touched,
+  dirty: control.dirty
+} | json }}
+
+
+
+ `, + }; + }, +}; diff --git a/tedi/components/form/time-field/time-picker-modal.component.html b/tedi/components/form/time-field/time-picker-modal.component.html new file mode 100644 index 000000000..b08187f67 --- /dev/null +++ b/tedi/components/form/time-field/time-picker-modal.component.html @@ -0,0 +1,26 @@ +
+ + +

{{ "time-field.modal-title" | tediTranslate }}

+
+ + + + + + + +
+
diff --git a/tedi/components/form/time-field/time-picker-modal.component.scss b/tedi/components/form/time-field/time-picker-modal.component.scss new file mode 100644 index 000000000..c626ea320 --- /dev/null +++ b/tedi/components/form/time-field/time-picker-modal.component.scss @@ -0,0 +1,14 @@ +.tedi-time-picker-modal { + --_tedi-modal-body-padding: 0; + + .tedi-time-picker { + width: 100%; + + --tedi-time-picker-width: 100%; + } +} + +.tedi-time-picker-modal__content { + display: flex; + justify-content: center; +} diff --git a/tedi/components/form/time-field/time-picker-modal.component.spec.ts b/tedi/components/form/time-field/time-picker-modal.component.spec.ts new file mode 100644 index 000000000..24eb637ba --- /dev/null +++ b/tedi/components/form/time-field/time-picker-modal.component.spec.ts @@ -0,0 +1,133 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { + TimePickerModalComponent, + TimePickerModalData, +} from "./time-picker-modal.component"; +import { ModalRef } from "../../overlay/modal/modal-ref"; +import { MODAL_DATA } from "../../overlay/modal/modal.types"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; + +describe("TimePickerModalComponent", () => { + let fixture: ComponentFixture; + let component: TimePickerModalComponent; + let el: HTMLElement; + let closeSpy: jest.Mock; + + const baseData: TimePickerModalData = { + value: "09:30", + variant: "scroll", + timeSlots: [], + columns: 3, + showSlotIndicator: false, + minuteStep: 1, + }; + + const setup = (data: Partial = {}) => { + closeSpy = jest.fn(); + TestBed.configureTestingModule({ + imports: [TimePickerModalComponent], + providers: [ + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + { provide: MODAL_DATA, useValue: { ...baseData, ...data } }, + { provide: ModalRef, useValue: { close: closeSpy } }, + ], + }); + fixture = TestBed.createComponent(TimePickerModalComponent); + component = fixture.componentInstance; + el = fixture.nativeElement; + fixture.detectChanges(); + }; + + it("should initialize draft from the provided value", () => { + setup(); + expect(component.draft()).toBe("09:30"); + }); + + it("should close with undefined when cancel is clicked", () => { + setup(); + component.cancel(); + expect(closeSpy).toHaveBeenCalledWith(undefined); + }); + + it("should close with the current draft when confirm is called", () => { + setup(); + component.draft.set("14:45"); + component.confirm(); + expect(closeSpy).toHaveBeenCalledWith("14:45"); + }); + + it("should close with the current draft when the form is submitted", () => { + setup(); + component.draft.set("11:00"); + const form = el.querySelector("form")!; + form.dispatchEvent(new Event("submit", { cancelable: true })); + expect(closeSpy).toHaveBeenCalledWith("11:00"); + }); + + it("should submit when Enter is pressed on a non-button target inside the host", () => { + setup(); + component.draft.set("10:15"); + + const target = el.querySelector(".tedi-time-picker__column") as HTMLElement; + expect(target).toBeTruthy(); + + target.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + + expect(closeSpy).toHaveBeenCalledWith("10:15"); + }); + + it("should NOT intercept Enter when pressed on a footer button", () => { + setup(); + component.draft.set("12:00"); + + const submitButton = el.querySelector( + 'button[type="submit"]', + ) as HTMLButtonElement; + expect(submitButton).toBeTruthy(); + + submitButton.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it("should not submit on non-Enter keys", () => { + setup(); + const target = el.querySelector(".tedi-time-picker__column") as HTMLElement; + target.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }), + ); + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it("should remove the keydown listener on destroy", () => { + setup(); + fixture.destroy(); + + const stray = document.createElement("div"); + stray.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it("should render the slots variant when configured", () => { + setup({ variant: "slots", timeSlots: ["08:00", "08:30", "09:00"] }); + expect(el.querySelector(".tedi-time-picker__grid")).toBeTruthy(); + expect(el.querySelectorAll(".tedi-time-picker__slot").length).toBe(3); + }); + + it("should update draft when the inner time-picker emits valueChange", () => { + setup({ variant: "slots", timeSlots: ["08:00", "08:30", "09:00"] }); + const inputs = el.querySelectorAll( + '.tedi-time-picker__grid input[type="radio"]', + ); + inputs[1].checked = true; + inputs[1].dispatchEvent(new Event("change", { bubbles: true })); + fixture.detectChanges(); + expect(component.draft()).toBe("08:30"); + }); +}); diff --git a/tedi/components/form/time-field/time-picker-modal.component.ts b/tedi/components/form/time-field/time-picker-modal.component.ts new file mode 100644 index 000000000..665ef1fb0 --- /dev/null +++ b/tedi/components/form/time-field/time-picker-modal.component.ts @@ -0,0 +1,87 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + OnDestroy, + signal, + ViewEncapsulation, +} from "@angular/core"; +import { ButtonComponent } from "../../buttons/button/button.component"; +import { ModalComponent } from "../../overlay/modal/modal.component"; +import { ModalContentComponent } from "../../overlay/modal/modal-content/modal-content.component"; +import { ModalFooterComponent } from "../../overlay/modal/modal-footer/modal-footer.component"; +import { ModalHeaderComponent } from "../../overlay/modal/modal-header/modal-header.component"; +import { ModalRef } from "../../overlay/modal/modal-ref"; +import { MODAL_DATA } from "../../overlay/modal/modal.types"; +import { TediTranslationPipe } from "../../../services/translation/translation.pipe"; +import { + TimePickerComponent, + TimePickerVariant, +} from "../time-picker/time-picker.component"; + +export interface TimePickerModalData { + value: string | null; + variant: TimePickerVariant; + timeSlots: string[]; + columns: number; + showSlotIndicator: boolean; + minuteStep: number; +} + +@Component({ + selector: "tedi-time-picker-modal", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ + ButtonComponent, + ModalComponent, + ModalContentComponent, + ModalFooterComponent, + ModalHeaderComponent, + TediTranslationPipe, + TimePickerComponent, + ], + templateUrl: "./time-picker-modal.component.html", + styleUrl: "./time-picker-modal.component.scss", +}) +export class TimePickerModalComponent implements AfterViewInit, OnDestroy { + readonly data = inject(MODAL_DATA) as TimePickerModalData; + private readonly ref = inject(ModalRef); + private readonly host = inject(ElementRef); + + readonly draft = signal(this.data.value); + + ngAfterViewInit(): void { + this.host.nativeElement.addEventListener("keydown", this.onCaptureKeydown, true); + } + + ngOnDestroy(): void { + this.host.nativeElement.removeEventListener("keydown", this.onCaptureKeydown, true); + } + + cancel(): void { + this.ref.close(undefined); + } + + confirm(): void { + this.ref.close(this.draft()); + } + + onSubmit(event: Event): void { + event.preventDefault(); + this.confirm(); + } + + private onCaptureKeydown = (event: KeyboardEvent): void => { + if (event.key !== "Enter") return; + if (event.target instanceof HTMLButtonElement) return; + const form = this.host.nativeElement.querySelector("form"); + if (!form) return; + event.preventDefault(); + event.stopPropagation(); + form.requestSubmit(); + }; +} diff --git a/tedi/components/form/time-picker/index.ts b/tedi/components/form/time-picker/index.ts new file mode 100644 index 000000000..8080986fb --- /dev/null +++ b/tedi/components/form/time-picker/index.ts @@ -0,0 +1 @@ +export * from "./time-picker.component"; diff --git a/tedi/components/form/time-picker/time-picker.component.html b/tedi/components/form/time-picker/time-picker.component.html new file mode 100644 index 000000000..da65ef297 --- /dev/null +++ b/tedi/components/form/time-picker/time-picker.component.html @@ -0,0 +1,116 @@ +@if (variant() === 'scroll') { +
+
+ @for (hour of hours; track hour; let i = $index) { + + } +
+
+
+ @for (minute of minutes(); track minute; let i = $index) { + + } +
+
+} @else if (variant() === 'dropdown') { + @if (timeSlots().length === 0) { +

{{ 'time-picker.no-slots' | tediTranslate }}

+ } @else { +
+ @for (slot of timeSlots(); track slot; let i = $index) { +
+ {{ slot }} +
+ } +
+ } +} @else if (variant() === 'slots') { + @if (timeSlots().length === 0) { +

{{ 'time-picker.no-slots' | tediTranslate }}

+ } @else { + + @for (slot of timeSlots(); track slot; let i = $index) { + + } + + } +} diff --git a/tedi/components/form/time-picker/time-picker.component.scss b/tedi/components/form/time-picker/time-picker.component.scss new file mode 100644 index 000000000..5a4d51ff9 --- /dev/null +++ b/tedi/components/form/time-picker/time-picker.component.scss @@ -0,0 +1,197 @@ +@use "@tedi-design-system/core/bootstrap-utility/breakpoints"; + +.tedi-time-picker { + --tedi-time-picker-item-height: var(--tedi-dimensions-16); + --tedi-time-picker-columns-height: 12.5rem; + --tedi-time-picker-width: var(--tedi-containers-01); + --_tedi-time-picker-snap-padding: calc((var(--tedi-time-picker-columns-height) - var(--tedi-time-picker-item-height)) / 2); + + display: block; + width: fit-content; + + &--disabled { + pointer-events: none; + opacity: 0.5; + } + + &--bordered { + overflow: hidden; + border: var(--tedi-borders-01) solid var(--general-border-primary); + border-radius: var(--card-radius-rounded); + } + + @include breakpoints.media-breakpoint-down(lg) { + --tedi-time-picker-item-height: var(--tedi-dimensions-18); + --tedi-time-picker-columns-height: 15.75rem; + } +} + +.tedi-time-picker__columns { + position: relative; + display: flex; + width: var(--tedi-time-picker-width); + height: var(--tedi-time-picker-columns-height); + overflow: hidden; + user-select: none; + background: var(--card-background-primary); + + &::before { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + content: ""; + background: linear-gradient(to bottom, + var(--card-background-primary) 0%, + transparent 30%, + transparent 70%, + var(--card-background-primary) 100%); + } + + &::after { + position: absolute; + top: 50%; + right: 0; + left: 0; + z-index: 1; + height: var(--tedi-time-picker-item-height); + pointer-events: none; + content: ""; + background: var(--form-datepicker-date-selected); + transform: translateY(-50%); + } +} + +.tedi-time-picker__column { + position: relative; + z-index: 2; + flex: 1; + height: 100%; + padding: var(--_tedi-time-picker-snap-padding) 0; + overflow-y: scroll; + overscroll-behavior-y: contain; + scroll-snap-type: y mandatory; + scroll-padding: var(--_tedi-time-picker-snap-padding); + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + &:focus { + outline: none; + } + + &:focus-visible { + z-index: 5; + outline: var(--tedi-borders-02) solid var(--tedi-primary-500); + outline-offset: calc(-1 * var(--tedi-borders-02)); + } +} + +.tedi-time-picker__separator { + position: relative; + z-index: 4; + width: var(--tedi-borders-02); + background: var(--general-border-primary); +} + +.tedi-time-picker__item { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: var(--tedi-time-picker-item-height); + padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x); + font-size: var(--body-regular-size); + color: var(--general-text-primary); + cursor: pointer; + scroll-snap-align: center; + scroll-snap-stop: always; + background: none; + border: 0; + + &:disabled { + cursor: not-allowed; + } + + &:focus, + &:focus-visible { + outline: none; + box-shadow: none; + } + + &--selected { + z-index: 2; + color: var(--form-datepicker-date-text-selected); + } +} + +.tedi-time-picker__dropdown { + display: flex; + flex-direction: column; + min-width: var(--tedi-time-picker-dropdown-min-width, auto); + overflow: auto; + border-radius: var(--dropdown-item-radius); +} + +.tedi-time-picker__empty { + padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x); + margin: 0; + font-size: var(--body-small-regular-size); + color: var(--general-text-secondary); + text-align: center; +} + +.tedi-time-picker__dropdown-item { + display: flex; + align-items: center; + width: 100%; + min-height: var(--tedi-dimensions-16); + padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x); + font-size: var(--body-regular-size); + color: var(--dropdown-item-default-text); + cursor: pointer; + background: var(--dropdown-item-default-background); + + &:hover { + color: var(--dropdown-item-hover-text); + background: var(--dropdown-item-hover-background); + } + + &:focus-visible { + outline: var(--tedi-borders-02) solid var(--tedi-primary-500); + outline-offset: calc(-1 * var(--tedi-borders-02)); + } + + &[aria-disabled="true"] { + cursor: not-allowed; + + &:hover { + color: var(--dropdown-item-default-text); + background: var(--dropdown-item-default-background); + } + } + + &--selected { + color: var(--dropdown-item-active-text); + background: var(--dropdown-item-active-background); + + &:hover { + color: var(--dropdown-item-active-text); + background: var(--dropdown-item-active-background); + } + } +} + +.tedi-time-picker__grid { + display: grid; + gap: var(--layout-grid-gutters-08); + padding: var(--card-padding-md-default); +} + diff --git a/tedi/components/form/time-picker/time-picker.component.spec.ts b/tedi/components/form/time-picker/time-picker.component.spec.ts new file mode 100644 index 000000000..fca01f70c --- /dev/null +++ b/tedi/components/form/time-picker/time-picker.component.spec.ts @@ -0,0 +1,650 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Component } from "@angular/core"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { TimePickerComponent } from "./time-picker.component"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; + +describe("TimePickerComponent", () => { + let fixture: ComponentFixture; + let component: TimePickerComponent; + let el: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TimePickerComponent], + providers: [ + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + + fixture = TestBed.createComponent(TimePickerComponent); + component = fixture.componentInstance; + el = fixture.nativeElement; + fixture.detectChanges(); + }); + + it("should create component", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize with null value", () => { + expect(component.value()).toBeNull(); + }); + + describe("scroll variant", () => { + it("should render two columns by default", () => { + const columns = el.querySelectorAll(".tedi-time-picker__column"); + expect(columns.length).toBe(2); + }); + + it("should render 24 hour items", () => { + const hourColumn = el.querySelectorAll(".tedi-time-picker__column")[0]; + const items = hourColumn.querySelectorAll(".tedi-time-picker__item"); + expect(items.length).toBe(24); + }); + + it("should render 60 minute items by default", () => { + const minuteColumn = el.querySelectorAll(".tedi-time-picker__column")[1]; + const items = minuteColumn.querySelectorAll(".tedi-time-picker__item"); + expect(items.length).toBe(60); + }); + + it("should render minute items based on minuteStep", () => { + fixture.componentRef.setInput("minuteStep", 15); + fixture.detectChanges(); + + const minuteColumn = el.querySelectorAll(".tedi-time-picker__column")[1]; + const items = minuteColumn.querySelectorAll(".tedi-time-picker__item"); + expect(items.length).toBe(4); + }); + + it("should select hour on click", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const hourItems = el + .querySelectorAll(".tedi-time-picker__column")[0] + .querySelectorAll(".tedi-time-picker__item"); + (hourItems[14] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(component.value()).toBe("14:00"); + expect(onChange).toHaveBeenCalledWith("14:00"); + }); + + it("should move focus to minute column after selecting an hour", () => { + const columns = el.querySelectorAll(".tedi-time-picker__column"); + const hourItems = columns[0].querySelectorAll(".tedi-time-picker__item"); + + (hourItems[8] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(document.activeElement).toBe(columns[1]); + }); + + it("should select minute on click", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + component.writeValue("14:00"); + fixture.detectChanges(); + + const minuteItems = el + .querySelectorAll(".tedi-time-picker__column")[1] + .querySelectorAll(".tedi-time-picker__item"); + (minuteItems[30] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(component.value()).toBe("14:30"); + expect(onChange).toHaveBeenCalledWith("14:30"); + }); + + it("should highlight selected hour and minute", () => { + component.writeValue("09:15"); + fixture.detectChanges(); + + const hourItems = el + .querySelectorAll(".tedi-time-picker__column")[0] + .querySelectorAll(".tedi-time-picker__item"); + expect(hourItems[9].classList.contains("tedi-time-picker__item--selected")).toBe(true); + + const minuteItems = el + .querySelectorAll(".tedi-time-picker__column")[1] + .querySelectorAll(".tedi-time-picker__item"); + expect(minuteItems[15].classList.contains("tedi-time-picker__item--selected")).toBe(true); + }); + + it("should have separator between columns", () => { + expect(el.querySelector(".tedi-time-picker__separator")).toBeTruthy(); + }); + + it("should have listbox role on columns", () => { + const columns = el.querySelectorAll(".tedi-time-picker__column"); + expect(columns[0].getAttribute("role")).toBe("listbox"); + expect(columns[1].getAttribute("role")).toBe("listbox"); + }); + + it("should set aria-selected on selected items", () => { + component.writeValue("03:05"); + fixture.detectChanges(); + + const hourItems = el + .querySelectorAll(".tedi-time-picker__column")[0] + .querySelectorAll(".tedi-time-picker__item"); + expect(hourItems[3].getAttribute("aria-selected")).toBe("true"); + expect(hourItems[0].getAttribute("aria-selected")).toBe("false"); + }); + + describe("column tabindex and aria", () => { + it("should expose tabindex 0 on each column", () => { + const columns = el.querySelectorAll(".tedi-time-picker__column"); + expect(columns[0].getAttribute("tabindex")).toBe("0"); + expect(columns[1].getAttribute("tabindex")).toBe("0"); + }); + + it("should set tabindex -1 on every item", () => { + const items = el + .querySelectorAll(".tedi-time-picker__column")[0] + .querySelectorAll(".tedi-time-picker__item"); + Array.from(items).forEach((item) => { + expect(item.getAttribute("tabindex")).toBe("-1"); + }); + }); + + it("should set tabindex -1 on columns when disabled", () => { + fixture.componentRef.setInput("disabled", true); + fixture.detectChanges(); + + const columns = el.querySelectorAll(".tedi-time-picker__column"); + expect(columns[0].getAttribute("tabindex")).toBe("-1"); + expect(columns[1].getAttribute("tabindex")).toBe("-1"); + }); + + it("should set aria-activedescendant to selected item id", () => { + component.writeValue("05:30"); + fixture.detectChanges(); + + const columns = el.querySelectorAll(".tedi-time-picker__column"); + const hourItems = columns[0].querySelectorAll(".tedi-time-picker__item"); + const minuteItems = columns[1].querySelectorAll(".tedi-time-picker__item"); + + expect(columns[0].getAttribute("aria-activedescendant")).toBe( + hourItems[5].getAttribute("id"), + ); + expect(columns[1].getAttribute("aria-activedescendant")).toBe( + minuteItems[30].getAttribute("id"), + ); + }); + }); + + describe("keyboard navigation", () => { + const dispatchKey = (element: Element, key: string) => { + element.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + }; + + it("should select next value with ArrowDown", () => { + component.writeValue("05:00"); + fixture.detectChanges(); + + const hourColumn = el.querySelectorAll(".tedi-time-picker__column")[0]; + dispatchKey(hourColumn, "ArrowDown"); + + expect(component.value()).toBe("06:00"); + }); + + it("should select previous value with ArrowUp", () => { + component.writeValue("05:00"); + fixture.detectChanges(); + + const hourColumn = el.querySelectorAll(".tedi-time-picker__column")[0]; + dispatchKey(hourColumn, "ArrowUp"); + + expect(component.value()).toBe("04:00"); + }); + + it("should jump to first hour with Home", () => { + component.writeValue("10:00"); + fixture.detectChanges(); + + const hourColumn = el.querySelectorAll(".tedi-time-picker__column")[0]; + dispatchKey(hourColumn, "Home"); + + expect(component.value()).toBe("00:00"); + }); + + it("should jump to last hour with End", () => { + component.writeValue("00:00"); + fixture.detectChanges(); + + const hourColumn = el.querySelectorAll(".tedi-time-picker__column")[0]; + dispatchKey(hourColumn, "End"); + + expect(component.value()).toBe("23:00"); + }); + + it("should advance 5 with PageDown", () => { + component.writeValue("00:00"); + fixture.detectChanges(); + + const hourColumn = el.querySelectorAll(".tedi-time-picker__column")[0]; + dispatchKey(hourColumn, "PageDown"); + + expect(component.value()).toBe("05:00"); + }); + + it("should rewind 5 with PageUp", () => { + component.writeValue("10:00"); + fixture.detectChanges(); + + const hourColumn = el.querySelectorAll(".tedi-time-picker__column")[0]; + dispatchKey(hourColumn, "PageUp"); + + expect(component.value()).toBe("05:00"); + }); + + it("should advance focus to minute column on Enter from hour column", () => { + const columns = el.querySelectorAll(".tedi-time-picker__column"); + (columns[0] as HTMLElement).focus(); + dispatchKey(columns[0], "Enter"); + + expect(document.activeElement).toBe(columns[1]); + }); + + it("should wrap to last hour on ArrowUp at first", () => { + component.writeValue("00:00"); + fixture.detectChanges(); + + const hourColumn = el.querySelectorAll(".tedi-time-picker__column")[0]; + dispatchKey(hourColumn, "ArrowUp"); + + expect(component.value()).toBe("23:00"); + }); + + it("should wrap to first hour on ArrowDown at last", () => { + component.writeValue("23:00"); + fixture.detectChanges(); + + const hourColumn = el.querySelectorAll(".tedi-time-picker__column")[0]; + dispatchKey(hourColumn, "ArrowDown"); + + expect(component.value()).toBe("00:00"); + }); + + it("should wrap minute column on ArrowDown at last", () => { + component.writeValue("01:59"); + fixture.detectChanges(); + + const minuteColumn = el.querySelectorAll(".tedi-time-picker__column")[1]; + dispatchKey(minuteColumn, "ArrowDown"); + + expect(component.value()).toBe("01:00"); + }); + + it("should not trap Tab when trapFocus is false", () => { + const columns = el.querySelectorAll(".tedi-time-picker__column"); + (columns[0] as HTMLElement).focus(); + dispatchKey(columns[0], "Tab"); + + expect(document.activeElement).not.toBe(columns[1]); + }); + + describe("with trapFocus enabled", () => { + beforeEach(() => { + fixture.componentRef.setInput("trapFocus", true); + fixture.detectChanges(); + }); + + it("should move focus from hour to minute column on Tab", () => { + const columns = el.querySelectorAll(".tedi-time-picker__column"); + (columns[0] as HTMLElement).focus(); + dispatchKey(columns[0], "Tab"); + + expect(document.activeElement).toBe(columns[1]); + }); + + it("should move focus from minute to hour column on Tab", () => { + const columns = el.querySelectorAll(".tedi-time-picker__column"); + (columns[1] as HTMLElement).focus(); + dispatchKey(columns[1], "Tab"); + + expect(document.activeElement).toBe(columns[0]); + }); + }); + }); + }); + + describe("dropdown variant", () => { + const slots = ["12:30", "13:00", "13:30", "14:00", "14:30"]; + + beforeEach(() => { + fixture.componentRef.setInput("variant", "dropdown"); + fixture.componentRef.setInput("timeSlots", slots); + fixture.detectChanges(); + }); + + it("should render dropdown items", () => { + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + expect(items.length).toBe(5); + }); + + it("should have listbox role", () => { + const list = el.querySelector(".tedi-time-picker__dropdown"); + expect(list?.getAttribute("role")).toBe("listbox"); + }); + + it("should select item on click", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + (items[2] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(component.value()).toBe("13:30"); + expect(onChange).toHaveBeenCalledWith("13:30"); + }); + + it("should highlight selected item", () => { + component.writeValue("13:00"); + fixture.detectChanges(); + + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + expect(items[1].classList.contains("tedi-time-picker__dropdown-item--selected")).toBe(true); + expect(items[0].classList.contains("tedi-time-picker__dropdown-item--selected")).toBe(false); + }); + + it("should set aria-selected on selected item", () => { + component.writeValue("14:00"); + fixture.detectChanges(); + + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + expect(items[3].getAttribute("aria-selected")).toBe("true"); + expect(items[0].getAttribute("aria-selected")).toBe("false"); + }); + + describe("roving tabindex", () => { + it("should set tabindex 0 on first item when no selection", () => { + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + expect(items[0].getAttribute("tabindex")).toBe("0"); + expect(items[1].getAttribute("tabindex")).toBe("-1"); + }); + + it("should set tabindex 0 on selected item", () => { + component.writeValue("13:30"); + fixture.detectChanges(); + + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + expect(items[2].getAttribute("tabindex")).toBe("0"); + expect(items[0].getAttribute("tabindex")).toBe("-1"); + }); + }); + + describe("keyboard navigation", () => { + const dispatchKey = (element: HTMLElement, key: string) => { + element.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + }; + + it("should move focus with ArrowDown", () => { + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + (items[0] as HTMLElement).focus(); + dispatchKey(items[0] as HTMLElement, "ArrowDown"); + expect(document.activeElement).toBe(items[1]); + }); + + it("should move focus with ArrowUp", () => { + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + (items[2] as HTMLElement).focus(); + dispatchKey(items[2] as HTMLElement, "ArrowUp"); + expect(document.activeElement).toBe(items[1]); + }); + + it("should select item with Enter", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + (items[1] as HTMLElement).focus(); + dispatchKey(items[1] as HTMLElement, "Enter"); + + expect(component.value()).toBe("13:00"); + expect(onChange).toHaveBeenCalledWith("13:00"); + }); + + it("should not move past first item with ArrowUp", () => { + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + (items[0] as HTMLElement).focus(); + dispatchKey(items[0] as HTMLElement, "ArrowUp"); + expect(document.activeElement).toBe(items[0]); + }); + + it("should not move past last item with ArrowDown", () => { + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + (items[4] as HTMLElement).focus(); + dispatchKey(items[4] as HTMLElement, "ArrowDown"); + expect(document.activeElement).toBe(items[4]); + }); + + it("should move focus to first item with Home", () => { + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + (items[3] as HTMLElement).focus(); + dispatchKey(items[3] as HTMLElement, "Home"); + expect(document.activeElement).toBe(items[0]); + }); + + it("should move focus to last item with End", () => { + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + (items[0] as HTMLElement).focus(); + dispatchKey(items[0] as HTMLElement, "End"); + expect(document.activeElement).toBe(items[4]); + }); + + it("should not trap Tab when trapFocus is false", () => { + const closeRequested = jest.spyOn(component.closeRequested, "emit"); + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + (items[0] as HTMLElement).focus(); + dispatchKey(items[0] as HTMLElement, "Tab"); + + expect(closeRequested).not.toHaveBeenCalled(); + }); + + it("should emit closeRequested on Tab when trapFocus is true", () => { + fixture.componentRef.setInput("trapFocus", true); + fixture.detectChanges(); + + const closeRequested = jest.spyOn(component.closeRequested, "emit"); + const items = el.querySelectorAll(".tedi-time-picker__dropdown-item"); + (items[0] as HTMLElement).focus(); + dispatchKey(items[0] as HTMLElement, "Tab"); + + expect(closeRequested).toHaveBeenCalled(); + }); + }); + }); + + describe("slots variant", () => { + const slots = ["09:00", "10:30", "11:00", "14:00", "15:30", "16:00"]; + + beforeEach(() => { + fixture.componentRef.setInput("variant", "slots"); + fixture.componentRef.setInput("timeSlots", slots); + fixture.detectChanges(); + }); + + it("should render a radio card per slot", () => { + const cards = el.querySelectorAll(".tedi-time-picker__slot"); + expect(cards.length).toBe(6); + const inputs = el.querySelectorAll( + '.tedi-time-picker__grid input[type="radio"]', + ); + expect(inputs.length).toBe(6); + }); + + it("should select slot when its radio input changes", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const inputs = el.querySelectorAll( + '.tedi-time-picker__grid input[type="radio"]', + ); + inputs[2].checked = true; + inputs[2].dispatchEvent(new Event("change", { bubbles: true })); + fixture.detectChanges(); + + expect(component.value()).toBe("11:00"); + expect(onChange).toHaveBeenCalledWith("11:00"); + }); + + it("should mark the matching radio input as checked", () => { + component.writeValue("10:30"); + fixture.detectChanges(); + + const inputs = el.querySelectorAll( + '.tedi-time-picker__grid input[type="radio"]', + ); + expect(inputs[1].checked).toBe(true); + expect(inputs[0].checked).toBe(false); + }); + + it("should render grid with configurable columns", () => { + fixture.componentRef.setInput("columns", 2); + fixture.detectChanges(); + + const grid = el.querySelector(".tedi-time-picker__grid") as HTMLElement; + expect(grid.style.gridTemplateColumns).toBe("repeat(2, 1fr)"); + }); + + it("should have radiogroup role on grid", () => { + const grid = el.querySelector(".tedi-time-picker__grid"); + expect(grid?.getAttribute("role")).toBe("radiogroup"); + }); + + it("should share a single radio group name across all slots", () => { + const inputs = el.querySelectorAll( + '.tedi-time-picker__grid input[type="radio"]', + ); + const names = new Set(Array.from(inputs).map((i) => i.name)); + expect(names.size).toBe(1); + }); + + it("should hide the radio indicator by default", () => { + const card = el.querySelector(".tedi-time-picker__slot"); + expect(card?.classList.contains("tedi-radio-card--hide-indicator")).toBe( + true, + ); + }); + + it("should show the radio indicator when showSlotIndicator is true", () => { + fixture.componentRef.setInput("showSlotIndicator", true); + fixture.detectChanges(); + + const card = el.querySelector(".tedi-time-picker__slot"); + expect(card?.classList.contains("tedi-radio-card--hide-indicator")).toBe( + false, + ); + }); + + it("should disable every radio input when disabled", () => { + fixture.componentRef.setInput("disabled", true); + fixture.detectChanges(); + + const inputs = el.querySelectorAll( + '.tedi-time-picker__grid input[type="radio"]', + ); + expect(Array.from(inputs).every((i) => i.disabled)).toBe(true); + }); + }); + + describe("ControlValueAccessor", () => { + it("should set value via writeValue", () => { + component.writeValue("16:45"); + expect(component.value()).toBe("16:45"); + }); + + it("should handle null writeValue", () => { + component.writeValue(null); + expect(component.value()).toBeNull(); + }); + + it("should call onTouched on selection", () => { + const onTouched = jest.fn(); + component.registerOnTouched(onTouched); + + const hourItems = el + .querySelectorAll(".tedi-time-picker__column")[0] + .querySelectorAll(".tedi-time-picker__item"); + (hourItems[5] as HTMLButtonElement).click(); + + expect(onTouched).toHaveBeenCalled(); + }); + + it("should clear visual highlight when value is reset to null", () => { + component.writeValue("14:30"); + fixture.detectChanges(); + + let columns = el.querySelectorAll(".tedi-time-picker__column"); + let hourItems = columns[0].querySelectorAll(".tedi-time-picker__item"); + let minuteItems = columns[1].querySelectorAll(".tedi-time-picker__item"); + expect(hourItems[14].classList.contains("tedi-time-picker__item--selected")).toBe(true); + expect(minuteItems[30].classList.contains("tedi-time-picker__item--selected")).toBe(true); + + component.writeValue(null); + fixture.detectChanges(); + + columns = el.querySelectorAll(".tedi-time-picker__column"); + hourItems = columns[0].querySelectorAll(".tedi-time-picker__item"); + minuteItems = columns[1].querySelectorAll(".tedi-time-picker__item"); + expect(hourItems[0].classList.contains("tedi-time-picker__item--selected")).toBe(true); + expect(hourItems[14].classList.contains("tedi-time-picker__item--selected")).toBe(false); + expect(minuteItems[0].classList.contains("tedi-time-picker__item--selected")).toBe(true); + expect(minuteItems[30].classList.contains("tedi-time-picker__item--selected")).toBe(false); + }); + }); +}); + +@Component({ + standalone: true, + imports: [TimePickerComponent, ReactiveFormsModule], + template: ``, +}) +class TestHostComponent { + control = new FormControl(null); +} + +describe("TimePickerComponent with ReactiveFormsModule", () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let el: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + providers: [ + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + el = fixture.nativeElement; + fixture.detectChanges(); + }); + + it("should sync FormControl value to component", () => { + host.control.setValue("12:00"); + fixture.detectChanges(); + + const hourItems = el + .querySelectorAll(".tedi-time-picker__column")[0] + .querySelectorAll(".tedi-time-picker__item"); + expect(hourItems[12].classList.contains("tedi-time-picker__item--selected")).toBe(true); + }); + + it("should sync component selection to FormControl", () => { + const hourItems = el + .querySelectorAll(".tedi-time-picker__column")[0] + .querySelectorAll(".tedi-time-picker__item"); + (hourItems[8] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(host.control.value).toBe("08:00"); + }); +}); diff --git a/tedi/components/form/time-picker/time-picker.component.ts b/tedi/components/form/time-picker/time-picker.component.ts new file mode 100644 index 000000000..ac2b1a783 --- /dev/null +++ b/tedi/components/form/time-picker/time-picker.component.ts @@ -0,0 +1,523 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + forwardRef, + inject, + input, + model, + output, + signal, + ViewEncapsulation, + viewChildren, + AfterViewInit, + OnDestroy, + effect, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { _IdGenerator } from "@angular/cdk/a11y"; +import { TediTranslationPipe } from "../../../services/translation/translation.pipe"; +import { isValidTime } from "../../../utils/time.util"; +import { RadioCardComponent } from "../radio-card/radio-card.component"; +import { RadioCardGroupComponent } from "../radio-card-group/radio-card-group.component"; +import { RadioComponent } from "../radio/radio.component"; + +export type TimePickerVariant = "scroll" | "slots" | "dropdown"; + +const DEFAULT_ITEM_HEIGHT = 40; +const SMOOTH_SCROLL_LOCK_MS = 400; +const INSTANT_SCROLL_LOCK_MS = 50; +const SCROLL_DEBOUNCE_MS = 150; + +type WheelType = "hour" | "minute"; + +@Component({ + selector: "tedi-time-picker", + standalone: true, + imports: [ + TediTranslationPipe, + RadioCardComponent, + RadioCardGroupComponent, + RadioComponent, + ], + templateUrl: "./time-picker.component.html", + styleUrl: "./time-picker.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + class: "tedi-time-picker", + "[class.tedi-time-picker--scroll]": "variant() === 'scroll'", + "[class.tedi-time-picker--slots]": "variant() === 'slots'", + "[class.tedi-time-picker--dropdown]": "variant() === 'dropdown'", + "[class.tedi-time-picker--disabled]": "isDisabled()", + "[class.tedi-time-picker--bordered]": "border()", + }, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimePickerComponent), + multi: true, + }, + ], +}) +export class TimePickerComponent implements ControlValueAccessor, AfterViewInit, OnDestroy { + /** Selected time in `HH:mm` format. Two-way bindable. */ + readonly value = model(null); + /** Visual variant. `scroll` shows hour/minute wheels, `slots` a grid of fixed times, `dropdown` a list. */ + readonly variant = input("scroll"); + /** Predefined times for the `slots` and `dropdown` variants (`HH:mm` strings). */ + readonly timeSlots = input([]); + /** Number of columns for the `slots` grid. */ + readonly columns = input(3); + /** Show the radio indicator dot on each card in the `slots` variant. Has no effect on other variants. */ + readonly showSlotIndicator = input(false); + /** Minute step for the `scroll` variant — e.g. `5` renders `00, 05, 10…`. */ + readonly minuteStep = input(1); + /** Disables interaction. Combines with the form-control disabled state. */ + readonly disabled = input(false); + /** Render the picker with a surrounding border — useful when embedded inside other content where it needs to stand apart. */ + readonly border = input(false); + /** Trap Tab between hour/minute columns (`scroll`) or emit `closeRequested` (`slots`/`dropdown`). */ + readonly trapFocus = input(false); + /** Emitted when the picker requests to be closed (Tab while `trapFocus` is `true`). */ + readonly closeRequested = output(); + + private readonly el = inject(ElementRef); + private readonly uniqueId = inject(_IdGenerator).getId("tedi-time-picker-"); + private readonly formDisabled = signal(false); + private onChange: (value: string | null) => void = () => {}; + private onTouched: () => void = () => {}; + private initialized = false; + + private readonly isProgrammaticScroll: Record = { hour: false, minute: false }; + private readonly scrollLockTimer: Partial>> = {}; + private readonly scrollDebounceTimer: Partial>> = {}; + private cachedItemHeight: number | null = null; + private resizeObserver: ResizeObserver | null = null; + + private readonly hourScrollIndex = signal(0); + private readonly minuteScrollIndex = signal(0); + + readonly hourColumns = viewChildren>("hourColumn"); + readonly minuteColumns = viewChildren>("minuteColumn"); + + /** + * The current `value` after validation. Invalid strings (e.g. `"25:99"`, + * `"abc"`, anything not matching `HH:mm`) collapse to `null`, so the picker + * renders as "no selection" instead of trying to scroll to a nonexistent + * row. The `value` model itself is left untouched — consumers using + * reactive forms can still see their invalid state. + */ + private readonly safeValue = computed(() => { + const v = this.value()?.trim(); + return v && isValidTime(v) ? v : null; + }); + + readonly selectedHour = computed(() => { + const val = this.safeValue(); + if (!val) return null; + return parseInt(val.split(":")[0], 10); + }); + + readonly selectedMinute = computed(() => { + const val = this.safeValue(); + if (!val) return null; + return parseInt(val.split(":")[1], 10); + }); + + readonly hours = Array.from({ length: 24 }, (_, i) => + String(i).padStart(2, "0"), + ); + + readonly minutes = computed(() => + Array.from( + { length: Math.ceil(60 / this.minuteStep()) }, + (_, i) => String(i * this.minuteStep()).padStart(2, "0"), + ), + ); + + readonly isDisabled = computed(() => this.disabled() || this.formDisabled()); + readonly gridStyle = computed(() => `grid-template-columns: repeat(${this.columns()}, 1fr)`); + + readonly selectedHourIndex = computed(() => this.selectedHour() ?? 0); + readonly selectedMinuteIndex = computed(() => { + const m = this.selectedMinute(); + if (m === null) return 0; + return Math.floor(m / this.minuteStep()); + }); + + readonly highlightedHourIndex = computed(() => this.hourScrollIndex()); + readonly highlightedMinuteIndex = computed(() => this.minuteScrollIndex()); + + readonly hourActiveId = computed(() => `${this.uniqueId}hour-${this.highlightedHourIndex()}`); + readonly minuteActiveId = computed(() => `${this.uniqueId}minute-${this.highlightedMinuteIndex()}`); + + hourItemId(index: number): string { + return `${this.uniqueId}hour-${index}`; + } + + minuteItemId(index: number): string { + return `${this.uniqueId}minute-${index}`; + } + + slotId(index: number): string { + return `${this.uniqueId}slot-${index}`; + } + + get radioGroupName(): string { + return `${this.uniqueId}slot-group`; + } + + onRadioChange(slot: string): void { + this.selectSlot(slot); + } + + constructor() { + effect(() => { + // Re-align the scroll wheel when the (sanitized) value changes. Reading + // safeValue here intentionally skips realignment for invalid strings. + this.safeValue(); + if (!this.initialized) return; + if (this.variant() !== "scroll") return; + this.alignScroll("instant"); + }); + } + + ngAfterViewInit(): void { + this.initialized = true; + this.cachedItemHeight = this.measureItemHeight(); + if (typeof ResizeObserver !== "undefined") { + this.resizeObserver = new ResizeObserver( + () => (this.cachedItemHeight = this.measureItemHeight()), + ); + this.resizeObserver.observe(this.el.nativeElement as HTMLElement); + } + requestAnimationFrame(() => this.alignScroll("instant")); + } + + private measureItemHeight(): number { + const root = this.el.nativeElement as HTMLElement; + const item = root.querySelector(".tedi-time-picker__item") as HTMLElement | null; + return item?.offsetHeight || DEFAULT_ITEM_HEIGHT; + } + + private getItemHeight(): number { + return this.cachedItemHeight ?? this.measureItemHeight(); + } + + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); + (Object.keys(this.scrollLockTimer) as WheelType[]).forEach((k) => { + const t = this.scrollLockTimer[k]; + if (t) clearTimeout(t); + }); + (Object.keys(this.scrollDebounceTimer) as WheelType[]).forEach((k) => { + const t = this.scrollDebounceTimer[k]; + if (t) clearTimeout(t); + }); + } + + writeValue(value: string | null): void { + this.value.set(value); + } + + registerOnChange(fn: (value: string | null) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(disabled: boolean): void { + this.formDisabled.set(disabled); + } + + selectHour(hour: string): void { + if (this.isDisabled()) return; + const minute = this.selectedMinute(); + const minuteStr = minute !== null ? String(minute).padStart(2, "0") : "00"; + const newValue = `${hour}:${minuteStr}`; + if (this.value() === newValue) return; + this.value.set(newValue); + this.onTouched(); + this.onChange(newValue); + } + + selectMinute(minute: string): void { + if (this.isDisabled()) return; + const hour = this.selectedHour(); + const hourStr = hour !== null ? String(hour).padStart(2, "0") : "00"; + const newValue = `${hourStr}:${minute}`; + if (this.value() === newValue) return; + this.value.set(newValue); + this.onTouched(); + this.onChange(newValue); + } + + selectSlot(slot: string): void { + if (this.isDisabled()) return; + this.value.set(slot); + this.onTouched(); + this.onChange(slot); + } + + isSlotSelected(slot: string): boolean { + return this.value() === slot; + } + + onHourClick(hour: string): void { + if (this.isDisabled()) return; + const idx = parseInt(hour, 10); + this.scrollColumnToIndex("hour", idx, "smooth"); + this.selectHour(hour); + this.focusOtherColumn("hour"); + } + + onMinuteClick(minute: string): void { + if (this.isDisabled()) return; + const list = this.minutes(); + const idx = list.indexOf(minute); + if (idx >= 0) this.scrollColumnToIndex("minute", idx, "smooth"); + this.selectMinute(minute); + this.minuteColumns()[0]?.nativeElement.focus({ preventScroll: true }); + } + + onColumnKeydown(event: KeyboardEvent, type: WheelType): void { + if (this.isDisabled()) return; + + if (event.key === "Tab") { + if (this.trapFocus()) { + event.preventDefault(); + event.stopPropagation(); + this.focusOtherColumn(type); + } + return; + } + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (type === "hour") this.focusOtherColumn("hour"); + return; + } + + const list = type === "hour" ? this.hours : this.minutes(); + if (!list.length) return; + + const currentIndex = type === "hour" ? this.highlightedHourIndex() : this.highlightedMinuteIndex(); + const move = this.computeWheelMove(event.key, currentIndex, list.length); + if (move === null) return; + + event.preventDefault(); + this.scrollColumnToIndex(type, move.index, move.wrapped ? "instant" : "smooth"); + if (type === "hour") { + this.selectHour(list[move.index]); + } else { + this.selectMinute(list[move.index]); + } + } + + private computeWheelMove(key: string, currentIndex: number, length: number): { index: number; wrapped: boolean } | null { + switch (key) { + case "ArrowDown": + return { index: (currentIndex + 1) % length, wrapped: currentIndex === length - 1 }; + case "ArrowUp": + return { index: (currentIndex - 1 + length) % length, wrapped: currentIndex === 0 }; + case "Home": + return { index: 0, wrapped: false }; + case "End": + return { index: length - 1, wrapped: false }; + case "PageDown": + return { index: Math.min(currentIndex + 5, length - 1), wrapped: false }; + case "PageUp": + return { index: Math.max(currentIndex - 5, 0), wrapped: false }; + default: + return null; + } + } + + onColumnScroll(type: WheelType): void { + const column = this.getColumnElement(type); + if (!column) return; + + const list = type === "hour" ? this.hours : this.minutes(); + const rawIndex = Math.round(column.scrollTop / this.getItemHeight()); + const index = Math.max(0, Math.min(rawIndex, list.length - 1)); + + if (type === "hour") this.hourScrollIndex.set(index); + else this.minuteScrollIndex.set(index); + + if (this.isProgrammaticScroll[type]) return; + + const existing = this.scrollDebounceTimer[type]; + if (existing) clearTimeout(existing); + + this.scrollDebounceTimer[type] = setTimeout(() => { + const col = this.getColumnElement(type); + if (!col) return; + const finalIdx = Math.max( + 0, + Math.min(Math.round(col.scrollTop / this.getItemHeight()), list.length - 1), + ); + if (type === "hour") this.selectHour(list[finalIdx]); + else this.selectMinute(list[finalIdx]); + }, SCROLL_DEBOUNCE_MS); + } + + /** Roving-tabindex helper for dropdown items: only the selected item (or the first + * when nothing is selected yet) is in the tab sequence. */ + getDropdownTabIndex(index: number): number { + const slots = this.timeSlots(); + const selectedIndex = slots.indexOf(this.value() ?? ""); + if (selectedIndex !== -1) { + return selectedIndex === index ? 0 : -1; + } + return index === 0 ? 0 : -1; + } + + onDropdownKeydown(event: KeyboardEvent): void { + if (this.isDisabled()) return; + + if (event.key === "Tab") { + if (this.trapFocus()) { + event.preventDefault(); + event.stopPropagation(); + this.closeRequested.emit(); + } + return; + } + + const target = event.target as HTMLElement; + const list = target.closest(".tedi-time-picker__dropdown"); + if (!list) return; + + const items = Array.from( + list.querySelectorAll(".tedi-time-picker__dropdown-item"), + ); + const currentIndex = items.indexOf(target); + if (currentIndex === -1) return; + + let nextIndex: number | null = null; + + switch (event.key) { + case "ArrowDown": + nextIndex = Math.min(currentIndex + 1, items.length - 1); + break; + case "ArrowUp": + nextIndex = Math.max(currentIndex - 1, 0); + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = items.length - 1; + break; + case "Enter": + case " ": + event.preventDefault(); + this.selectSlot(items[currentIndex].textContent!.trim()); + return; + default: + return; + } + + event.preventDefault(); + items[nextIndex].focus(); + } + + + private focusOtherColumn(currentType: WheelType): void { + const target = currentType === "hour" ? this.minuteColumns()[0] : this.hourColumns()[0]; + target?.nativeElement.focus({ preventScroll: true }); + } + + focusActiveItem(): void { + const variant = this.variant(); + if (variant === "scroll") { + this.hourColumns()[0]?.nativeElement?.focus({ preventScroll: true }); + return; + } + + const root = this.el.nativeElement as HTMLElement; + + if (variant === "slots") { + const inputs = root.querySelectorAll( + '.tedi-time-picker__grid input[type="radio"]', + ); + const checked = Array.from(inputs).find((input) => input.checked); + (checked ?? inputs[0])?.focus({ preventScroll: true }); + return; + } + + if (variant === "dropdown") { + const items = root.querySelectorAll( + ".tedi-time-picker__dropdown-item", + ); + const focusable = Array.from(items).find( + (item) => item.getAttribute("tabindex") === "0", + ); + (focusable ?? items[0])?.focus({ preventScroll: true }); + } + } + + scrollToSelected(): void { + this.alignScroll("instant"); + } + + private getColumnElement(type: WheelType): HTMLElement | undefined { + const columns = type === "hour" ? this.hourColumns() : this.minuteColumns(); + return columns[0]?.nativeElement; + } + + private alignScroll(behavior: ScrollBehavior): void { + if (this.variant() !== "scroll") return; + + const targetHour = this.selectedHourIndex(); + const targetMinute = this.selectedMinuteIndex(); + + if (behavior !== "smooth") { + this.hourScrollIndex.set(targetHour); + this.minuteScrollIndex.set(targetMinute); + } + + if (!this.isProgrammaticScroll.hour) { + this.scrollColumnToIndex("hour", targetHour, behavior); + } + if (!this.isProgrammaticScroll.minute) { + this.scrollColumnToIndex("minute", targetMinute, behavior); + } + } + + private scrollColumnToIndex(type: WheelType, index: number, behavior: ScrollBehavior = "auto"): void { + const column = this.getColumnElement(type); + if (!column) return; + + const target = index * this.getItemHeight(); + + if (behavior !== "smooth") { + if (type === "hour") this.hourScrollIndex.set(index); + else this.minuteScrollIndex.set(index); + } + + if (Math.abs(column.scrollTop - target) < 1) return; + + this.isProgrammaticScroll[type] = true; + + const existing = this.scrollLockTimer[type]; + if (existing) clearTimeout(existing); + + if (typeof column.scrollTo === "function") { + column.scrollTo({ top: target, behavior }); + } else { + column.scrollTop = target; + } + + this.scrollLockTimer[type] = setTimeout( + () => { + this.isProgrammaticScroll[type] = false; + }, + behavior === "smooth" ? SMOOTH_SCROLL_LOCK_MS : INSTANT_SCROLL_LOCK_MS, + ); + } +} diff --git a/tedi/components/form/time-picker/time-picker.stories.ts b/tedi/components/form/time-picker/time-picker.stories.ts new file mode 100644 index 000000000..a6e7f0757 --- /dev/null +++ b/tedi/components/form/time-picker/time-picker.stories.ts @@ -0,0 +1,277 @@ +import { + Meta, + StoryObj, + moduleMetadata, +} from "@storybook/angular"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { TimePickerComponent } from "./time-picker.component"; +import { RowComponent } from "../../helpers/grid/row/row.component"; +import { ColComponent } from "../../helpers/grid/col/col.component"; +import { AlertComponent } from "../../notifications/alert/alert.component"; +import { TextComponent } from "../../base/text/text.component"; + +/** + * Figma ↗
+ * Zeroheight ↗
+ * Standalone time picker component with scroll-wheel and predefined time slot variants. + */ + +export default { + title: "TEDI-Ready/Components/Form/TimePicker", + component: TimePickerComponent, + decorators: [ + moduleMetadata({ + imports: [TimePickerComponent, RowComponent, ColComponent, ReactiveFormsModule, AlertComponent, TextComponent], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-2.41.64?node-id=42943-146292&m=dev", + }, + }, + argTypes: { + value: { + description: "Selected time in HH:mm format. Two-way bindable.", + control: { type: "text" }, + table: { + category: "inputs", + type: { summary: "string | null" }, + defaultValue: { summary: "null" }, + }, + }, + variant: { + description: + "Visual variant — scroll wheels, predefined slot grid, or a dropdown list.", + control: { type: "radio" }, + options: ["scroll", "slots", "dropdown"], + table: { + category: "inputs", + type: { summary: "TimePickerVariant", detail: "scroll \nslots \ndropdown" }, + defaultValue: { summary: "scroll" }, + }, + }, + minuteStep: { + description: "Minute increment for the scroll variant — e.g. 5 renders 00, 05, 10…", + control: { type: "number" }, + table: { + category: "inputs", + type: { summary: "number" }, + defaultValue: { summary: "1" }, + }, + }, + timeSlots: { + description: "Predefined HH:mm strings rendered by the slots and dropdown variants.", + control: { type: "object" }, + table: { + category: "inputs", + type: { summary: "string[]" }, + defaultValue: { summary: "[]" }, + }, + }, + columns: { + description: "Number of columns rendered by the slots variant.", + control: { type: "number" }, + table: { + category: "inputs", + type: { summary: "number" }, + defaultValue: { summary: "3" }, + }, + }, + showSlotIndicator: { + description: + "Show the radio indicator dot on each card in the slots variant. Has no effect on other variants.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + disabled: { + description: "Disables interaction with the picker.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + border: { + description: + "Render the picker with a surrounding border — useful when the picker is embedded inside other content and needs to visually stand apart.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + trapFocus: { + description: + "Trap Tab inside the picker (scroll variant: between hour/minute columns; slots/dropdown: emits closeRequested).", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + }, +} as Meta; + +export const Default: StoryObj = { + args: { + value: "03:03", + variant: "scroll", + timeSlots: ["09:00", "09:30", "10:00", "10:30", "11:00", "11:30"], + columns: 3, + showSlotIndicator: false, + minuteStep: 1, + disabled: false, + border: false, + trapFocus: false, + }, + render: (args) => ({ + props: args, + template: ` +
+ +
+ `, + }), +}; + +export const ScrollWithStep: StoryObj = { + render: () => ({ + template: ` +
+ +
+ `, + }), +}; + +export const Disabled: StoryObj = { + render: () => ({ + template: ` +
+ +
+ `, + }), +}; + +export const Bordered: StoryObj = { + render: () => ({ + template: ` +
+ +
+ `, + }), + parameters: { + docs: { + description: { + story: + "Enable `[border]=\"true\"` to render the picker with a surrounding border. Useful when embedding the picker into other content where it needs to visually stand apart from the surroundings.", + }, + }, + }, +}; + +export const Slots: StoryObj = { + render: () => ({ + props: { + slots: ["09:30", "10:00", "11:30", "15:30", "18:30", "20:30"], + }, + template: ` + + +

Without indicator

+ +
+ +

With indicator

+ +
+
+ `, + }), + parameters: { + docs: { + description: { + story: + "The slots variant renders each predefined time as a secondary radio card. By default the radio indicator dot is hidden — set `[showSlotIndicator]=\"true\"` to surface it (e.g. when consumers want a more explicit radio-style affordance).", + }, + }, + }, +}; + +export const Dropdown: StoryObj = { + render: () => ({ + props: { + slots: ["12:30", "13:00", "13:30", "14:00", "14:30"], + }, + template: ` +
+ +
+ `, + }), +}; + +export const WithReactiveForms: StoryObj = { + render: () => { + const control = new FormControl("14:30"); + + return { + props: { control }, + template: ` + + +
+ +
+
+ + +
{{ {
+  value: control.value,
+  touched: control.touched,
+  dirty: control.dirty
+} | json }}
+
+
+
+ `, + }; + }, +}; diff --git a/tedi/components/helpers/scroll-fade/scroll-fade.component.scss b/tedi/components/helpers/scroll-fade/scroll-fade.component.scss index 4891af7da..06d56dc0b 100644 --- a/tedi/components/helpers/scroll-fade/scroll-fade.component.scss +++ b/tedi/components/helpers/scroll-fade/scroll-fade.component.scss @@ -1,4 +1,8 @@ -$percentages: (0, 10, 20); +$percentages: ( + 0, + 10, + 20 +); .tedi-scroll-fade { --_tedi-scroll-fade-top: 0%; @@ -15,13 +19,11 @@ $percentages: (0, 10, 20); min-height: 0; max-height: inherit; overflow: auto; - mask-image: linear-gradient( - to bottom, - transparent 0%, - black var(--_tedi-scroll-fade-top), - black calc(100% - var(--_tedi-scroll-fade-bottom)), - transparent 100% - ); + mask-image: linear-gradient(to bottom, + transparent 0%, + black var(--_tedi-scroll-fade-top), + black calc(100% - var(--_tedi-scroll-fade-bottom)), + transparent 100%); &--custom-scroll { &::-webkit-scrollbar { @@ -32,10 +34,10 @@ $percentages: (0, 10, 20); &::-webkit-scrollbar-thumb { background: var(--general-border-primary); border-radius: 100px; + } - &:hover { - background-color: var(--general-border-secondary); - } + &::-webkit-scrollbar-thumb:hover { + background-color: var(--general-border-secondary); } &::-webkit-scrollbar-track { diff --git a/tedi/components/helpers/scroll-fade/scroll-fade.component.ts b/tedi/components/helpers/scroll-fade/scroll-fade.component.ts index 66c23eab2..16c77753a 100644 --- a/tedi/components/helpers/scroll-fade/scroll-fade.component.ts +++ b/tedi/components/helpers/scroll-fade/scroll-fade.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, ElementRef, + OnDestroy, ViewEncapsulation, computed, input, @@ -26,7 +27,7 @@ export type ScrollFadeScrollbar = "default" | "custom"; "[class]": "classes()", }, }) -export class ScrollFadeComponent implements AfterViewInit { +export class ScrollFadeComponent implements AfterViewInit, OnDestroy { /** Size of the fade gradient in percentages. */ readonly fadeSize = input(20); @@ -43,6 +44,7 @@ export class ScrollFadeComponent implements AfterViewInit { readonly scrolledToBottom = output(); private readonly innerRef = viewChild.required>("inner"); + private resizeObserver: ResizeObserver | null = null; private readonly fade = signal({ top: false, bottom: false }); @@ -81,6 +83,15 @@ export class ScrollFadeComponent implements AfterViewInit { ngAfterViewInit(): void { const el = this.innerRef().nativeElement; this.updateFade(el.scrollTop, el.scrollHeight, el.clientHeight); + + this.resizeObserver = new ResizeObserver(() => { + this.updateFade(el.scrollTop, el.scrollHeight, el.clientHeight); + }); + this.resizeObserver.observe(el); + } + + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); } private updateFade(scrollTop: number, scrollHeight: number, clientHeight: number): void { diff --git a/tedi/components/overlay/popover/popover-content/popover-content.component.ts b/tedi/components/overlay/popover/popover-content/popover-content.component.ts index 2f343797b..0a6d792c3 100644 --- a/tedi/components/overlay/popover/popover-content/popover-content.component.ts +++ b/tedi/components/overlay/popover/popover-content/popover-content.component.ts @@ -46,10 +46,11 @@ export class PopoverContentComponent { titleId = `popover-title-${popoverTitleId++}`; classes = computed(() => { - const classList = [ - "tedi-popover-content", - `tedi-popover-content--${this.maxWidth()}`, - ]; + const classList = ["tedi-popover-content"]; + const maxWidth = this.maxWidth(); + if (maxWidth !== "none") { + classList.push(`tedi-popover-content--${maxWidth}`); + } return classList.join(" "); }); diff --git a/tedi/components/overlay/popover/popover.component.scss b/tedi/components/overlay/popover/popover.component.scss index eae835a06..ea84d98c5 100644 --- a/tedi/components/overlay/popover/popover.component.scss +++ b/tedi/components/overlay/popover/popover.component.scss @@ -1,5 +1,4 @@ $popover-max-width: ( - "none": none, "small": 240px, "medium": 480px, "large": 840px, diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts index 7665e5351..ac052fb94 100644 --- a/tedi/services/translation/translations.ts +++ b/tedi/services/translation/translations.ts @@ -957,6 +957,66 @@ export const translationsMap = { en: "Next years", ru: "Следующие годы", }, + "time-picker.hours": { + description: "Aria label for the hours listbox in the time picker.", + components: ["TimePicker"], + et: "Tunnid", + en: "Hours", + ru: "Часы", + }, + "time-picker.minutes": { + description: "Aria label for the minutes listbox in the time picker.", + components: ["TimePicker"], + et: "Minutid", + en: "Minutes", + ru: "Минуты", + }, + "time-field.clear": { + description: + "Label for the button that clears the selected time from the input field.", + components: ["TimeField"], + et: "Tühjenda kellaaeg", + en: "Clear time", + ru: "Очистить время", + }, + "time-field.select-time": { + description: "Label for the button that selects time.", + components: ["TimeField"], + et: "Vali kellaaeg", + en: "Select time", + ru: "Выбрать время", + }, + "time-field.modal-title": { + description: "Title shown in the mobile time picker modal header.", + components: ["TimeField"], + et: "Kellaaeg", + en: "Time", + ru: "Время", + }, + "time-field.confirm": { + description: + "Label for the confirm button in the mobile time picker modal.", + components: ["TimeField"], + et: "Kinnita", + en: "Confirm", + ru: "Подтвердить", + }, + "time-field.cancel": { + description: + "Label for the cancel button in the mobile time picker modal.", + components: ["TimeField"], + et: "Tühista", + en: "Cancel", + ru: "Отмена", + }, + "time-picker.no-slots": { + description: + "Empty-state message shown in the slots/dropdown time picker when no time slots have been provided.", + components: ["TimePicker", "TimeField"], + et: "Aegu ei ole määratud", + en: "No times available", + ru: "Нет доступных вариантов", + }, "vertical-stepper.completed": { description: "Label for screen-reader that this step is completed (visually hidden)", diff --git a/tedi/utils/time.util.spec.ts b/tedi/utils/time.util.spec.ts new file mode 100644 index 000000000..0e97200aa --- /dev/null +++ b/tedi/utils/time.util.spec.ts @@ -0,0 +1,94 @@ +import { isValidTime, normalizeTime } from "./time.util"; + +describe("time.util", () => { + describe("isValidTime", () => { + it.each(["00:00", "09:30", "23:59", "12:00"])( + "should return true for valid time %s", + (time) => { + expect(isValidTime(time)).toBe(true); + }, + ); + + it.each([ + "", + "9:30", + "9:5", + "24:00", + "12:60", + "abc", + "12:00:00", + "1:1", + ])("should return false for invalid time %s", (time) => { + expect(isValidTime(time)).toBe(false); + }); + + it("should return false for null/undefined", () => { + expect(isValidTime(null)).toBe(false); + expect(isValidTime(undefined)).toBe(false); + }); + + it("should trim before validating", () => { + expect(isValidTime(" 09:30 ")).toBe(true); + }); + }); + + describe("normalizeTime", () => { + it("should return '' for empty/whitespace input", () => { + expect(normalizeTime("")).toBe(""); + expect(normalizeTime(" ")).toBe(""); + }); + + it("should pass through already-valid HH:mm", () => { + expect(normalizeTime("09:30")).toBe("09:30"); + expect(normalizeTime("23:59")).toBe("23:59"); + }); + + it("should zero-pad H:m form", () => { + expect(normalizeTime("9:5")).toBe("09:05"); + expect(normalizeTime("14:5")).toBe("14:05"); + expect(normalizeTime("1:1")).toBe("01:01"); + }); + + it("should split 4-digit input as HH:mm", () => { + expect(normalizeTime("1155")).toBe("11:55"); + expect(normalizeTime("0930")).toBe("09:30"); + expect(normalizeTime("2359")).toBe("23:59"); + }); + + it("should split 3-digit input as H:mm", () => { + expect(normalizeTime("930")).toBe("09:30"); + expect(normalizeTime("159")).toBe("01:59"); + }); + + it("should treat any non-digit as separator", () => { + expect(normalizeTime("11.55")).toBe("11:55"); + expect(normalizeTime("11-55")).toBe("11:55"); + expect(normalizeTime("11 55")).toBe("11:55"); + expect(normalizeTime("11/55")).toBe("11:55"); + }); + + it("should trim leading/trailing whitespace", () => { + expect(normalizeTime(" 09:30 ")).toBe("09:30"); + expect(normalizeTime(" 1155 ")).toBe("11:55"); + }); + + it("should return null for out-of-range hours/minutes", () => { + expect(normalizeTime("24:00")).toBeNull(); + expect(normalizeTime("12:60")).toBeNull(); + expect(normalizeTime("2400")).toBeNull(); + expect(normalizeTime("1260")).toBeNull(); + expect(normalizeTime("489")).toBeNull(); + }); + + it("should return null for non-numeric input", () => { + expect(normalizeTime("abc")).toBeNull(); + expect(normalizeTime("12:ab")).toBeNull(); + }); + + it("should return null for digit counts other than 3 or 4 (without colon)", () => { + expect(normalizeTime("1")).toBeNull(); + expect(normalizeTime("12")).toBeNull(); + expect(normalizeTime("12345")).toBeNull(); + }); + }); +}); diff --git a/tedi/utils/time.util.ts b/tedi/utils/time.util.ts new file mode 100644 index 000000000..c25c0ffbd --- /dev/null +++ b/tedi/utils/time.util.ts @@ -0,0 +1,52 @@ +/** + * Checks if a string is a valid `HH:mm` time (00:00 – 23:59). + */ +export function isValidTime(time: string | null | undefined): boolean { + if (!time) return false; + return /^([01][0-9]|2[0-3]):[0-5][0-9]$/.test(time.trim()); +} + +/** + * Normalizes common typing patterns into `HH:mm`. + * + * Returns: + * - the canonical `HH:mm` string when the input can be normalized + * - `""` when the input is empty + * - `null` when the input is non-empty but cannot be normalized + * + * Examples: + * `"9:5"` → `"09:05"` + * `"14:5"` → `"14:05"` + * `"2359"` → `"23:59"` + * `"930"` → `"09:30"` + * `"11.55"` / `"11-55"` → `"11:55"` (any non-digit treated as separator) + * `"4:89"` / `"24:00"` → `null` + */ +export function normalizeTime(input: string): string | null { + const cleaned = input.trim(); + if (!cleaned) return ""; + + if (isValidTime(cleaned)) return cleaned; + + if (cleaned.includes(":")) { + const [hPart, mPart] = cleaned.split(":"); + const hour = parseInt(hPart, 10); + const min = parseInt(mPart, 10); + if (!isNaN(hour) && !isNaN(min) && hour >= 0 && hour <= 23 && min >= 0 && min <= 59) { + return `${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")}`; + } + return null; + } + + const digitsOnly = cleaned.replace(/[^0-9]/g, ""); + if (digitsOnly.length === 3) { + const candidate = `${digitsOnly.slice(0, 1).padStart(2, "0")}:${digitsOnly.slice(1)}`; + return isValidTime(candidate) ? candidate : null; + } + if (digitsOnly.length === 4) { + const candidate = `${digitsOnly.slice(0, 2)}:${digitsOnly.slice(2)}`; + return isValidTime(candidate) ? candidate : null; + } + + return null; +}