Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ This project ships with AI agent skills to help both contributors and consumers

### For consumers — `tedi-angular`

Helps you build UIs with `@tedi-design-system/angular`: component usage, forms integration, theming, and translation setup.
Helps you build UIs with `@tedi-design-system/angular`: component usage, forms integration, theming, and translation setup. Lives in [`skills/tedi-angular`](./skills/tedi-angular).

Install it into your agent (Claude Code, Cursor, Codex, and others) with the [`skills`](https://www.skills.sh/tedi-design-system/angular/tedi-angular) CLI:

```bash
npx skills add https://github.com/tedi-design-system/angular --skill tedi-angular
```

### For contributors — `contributing`

Expand Down
3 changes: 3 additions & 0 deletions skills/tedi-angular/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ Both entry points declare `[tedi-floating-button]`. The Community component is

- **`tedi-date-field`, not `tedi-date-picker`.** DatePicker is deprecated; DateField wraps a typed
input with a popover or modal calendar and supports `single`, `multiple` and `range`.
- **`tedi-date-time-field` when the value needs a date *and* a time.** It pairs the same typed
input with a calendar plus a TimePicker (side by side, or as a two-step date-then-time flow), in
`single` or `range` mode. Don't compose a DateField and a TimeField by hand.
- **`tedi-time-field`, not `tedi-time-picker`.** TimePicker is the bare picker surface behind
TimeField. On its own, with no value, the `scroll` wheel parks on `12:00` as display only, and
nothing is selected until the user picks.
Expand Down
41 changes: 41 additions & 0 deletions skills/tedi-angular/references/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ component's `ɵɵComponentDeclaration` carries its real selector; see
| RadioGroupComponent | `tedi-radio-group` | `string \| null` |
| ToggleComponent | `tedi-toggle` | `boolean` |
| DateFieldComponent | `tedi-date-field` | `Date \| Date[] \| DateRange \| null` |
| DateTimeFieldComponent | `tedi-date-time-field` | `Date \| DateRange \| null` (each end carries a time) |
| DatePickerComponent | `tedi-date-picker` | `Date \| null` — **deprecated**, use `DateFieldComponent` |
| TimeFieldComponent | `tedi-time-field` | `string \| null` (HH:mm) |
| TimePickerComponent | `tedi-time-picker` | `string \| null` (HH:mm) |
Expand Down Expand Up @@ -190,6 +191,46 @@ Pick the picker style with `pickerVariant` (`"scroll" | "slots" | "dropdown" | "

`TimePickerComponent` (`tedi-time-picker`) is the standalone picker surface behind TimeField — most consumers should reach for `tedi-time-field` instead.

## Date and Time Selection

Use `DateTimeFieldComponent` (`tedi-date-time-field`) when one value carries both a date and a time
— don't wire a DateField and a TimeField together by hand. It pairs the same typed input with a
popover holding the calendar and a TimePicker, in `single` or `range` mode (a range carries a time
on each end).

```html
<tedi-form-field>
<label tedi-label for="dt">Kuupäev ja kellaaeg</label>
<tedi-date-time-field inputId="dt" [formControl]="dateTimeControl" placeholder="pp.kk.aaaa tt:mm" />
</tedi-form-field>
```

`layout` picks how the two halves are arranged: `"side-by-side"` (default) shows the calendar and
the time picker together, `"multi-step"` asks for the date first and then advances to a separate
time step. `range` always renders side by side.

Set `availableTimes` — a `string[]` of `HH:mm` values, or a `(date: Date) => string[]` for
per-date slots — to replace the scroll wheel with a grid of predefined slots; `timeGridVariant`
(`"button" | "radio"`) and `slotColumns` shape that grid. Without it, `minuteStep` sets the wheel's
granularity.

```html
<tedi-date-time-field
inputId="dt-slots"
[formControl]="dateTimeControl"
layout="multi-step"
[availableTimes]="slotsForDate"
/>
```

It takes the same calendar inputs as DateField (`minDate`/`maxDate`, `disablePast`/`disableFuture`,
`disabledMatchers`, `availableDays`/`unavailableDays`, `selectionLevel`, `monthYearSelectType`,
`showWeekNumbers`, `numberOfMonths`). `useNativePicker` falls back to the OS
`<input type="datetime-local">` (single mode only) and `modal`/`fullscreen` open the picker in a
modal instead of the popover; both accept a breakpoint name (`"sm"`, `"md"`, ...) to switch below
that breakpoint. Like DateField it owns no label — compose it with `tedi-form-field` and
`tedi-label`.

## Value and event conventions

TEDI form controls hand you the **parsed value**, not a raw DOM event:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
align-items: center;
padding-left: var(--layout-grid-gutters-04);
font-family: inherit;
font-size: var(--body-regular-size);
font-weight: 500;
font-size: var(--heading-h6-size);
font-weight: var(--heading-h6-weight);
color: var(--general-text-primary);
text-transform: capitalize;
cursor: pointer;
Expand Down Expand Up @@ -66,7 +66,7 @@

&__select-arrow.tedi-icon {
flex-shrink: 0;
font-size: var(--tedi-size-09);
font-size: var(--icon-05);
color: var(--general-icon-tertiary);
}

Expand All @@ -75,13 +75,14 @@
}

&__static-label {
font-size: var(--body-regular-size);
font-weight: 500;
font-size: var(--heading-h6-size);
font-weight: var(--heading-h6-weight);
color: var(--general-text-primary);
text-transform: capitalize;
}

&--disabled {

.tedi-calendar-header__select,
.tedi-calendar-header__label-button {
pointer-events: none;
Expand Down
29 changes: 17 additions & 12 deletions src/tedi/components/content/calendar/calendar.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@
// the months into a column when they fit. Control the count per screen size
// with a BreakpointInput (e.g. [numberOfMonths]="{ xs: 1, lg: 2 }").
--_tedi-calendar-day-columns: 7;
--_tedi-calendar-month-width: calc(
var(--_tedi-calendar-day-columns) * var(--form-calendar-date-width) + 2 *
var(--card-padding-md-default)
);
--_tedi-calendar-padding-x: var(--card-padding-md-default);
--_tedi-calendar-month-width: calc(var(--_tedi-calendar-day-columns) * var(--form-calendar-date-width) + 2 * var(--_tedi-calendar-padding-x));

display: flex;
flex-direction: column;
Expand All @@ -20,12 +18,8 @@
// still hugs the columns it shows — no leftover whitespace. The fallback to
// --_tedi-calendar-month-count keeps it correct before/without the measurement
// (e.g. SSR): all months side by side.
width: calc(
var(
--_tedi-calendar-fit-columns,
var(--_tedi-calendar-month-count, 1)
) * var(--_tedi-calendar-month-width)
);
width: calc(var(--_tedi-calendar-fit-columns,
var(--_tedi-calendar-month-count, 1)) * var(--_tedi-calendar-month-width));
min-width: var(--_tedi-calendar-month-width);
height: fit-content;
background: var(--card-background-primary);
Expand All @@ -34,6 +28,12 @@
--_tedi-calendar-day-columns: 8;
}

// A month grid with the default padding is 312px, wider than a dialog or
// popover can be on a 320px phone. Tighten the sides there, as in Figma.
@media (width < 360px) {
--_tedi-calendar-padding-x: var(--card-padding-xs);
}

&__months {
display: flex;
flex-wrap: wrap;
Expand All @@ -44,12 +44,17 @@
&__month {
display: flex;
flex-direction: column;
padding: var(--card-padding-md-default);
padding: var(--card-padding-md-default) var(--_tedi-calendar-padding-x);
}

&__month .tedi-calendar-header {
margin-right: calc(-1 * var(--layout-grid-gutters-08));
margin-left: calc(-1 * var(--layout-grid-gutters-08));
}

&__footer {
padding-top: var(--layout-grid-gutters-08);
margin: 0 var(--card-padding-md-default) var(--card-padding-xs);
margin: 0 var(--_tedi-calendar-padding-x) var(--card-padding-xs);
border-top: var(--tedi-borders-01) solid var(--general-border-primary);

&:empty {
Expand Down
12 changes: 7 additions & 5 deletions src/tedi/components/content/calendar/calendar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@ import {
import { CalendarMonthGridComponent } from "./calendar-month-grid/calendar-month-grid.component";
import { CalendarYearGridComponent } from "./calendar-year-grid/calendar-year-grid.component";
import { CalendarHeaderComponent } from "./calendar-header/calendar-header.component";
import { CalendarView, DateFieldMode } from "./types";
import {
CalendarView,
DateFieldMode,
DayAvailabilityInput,
MonthPredicate,
YearPredicate,
} from "./types";

type CalendarValue = Date | Date[] | DateRange | null;
type DayPredicate = (date: Date) => boolean;
type DayAvailabilityInput = Date[] | DayPredicate | undefined;
type MonthPredicate = (month: Date) => boolean;
type YearPredicate = (year: Date) => boolean;

const YEAR_PAGE_SIZE = 12;

Expand Down
10 changes: 9 additions & 1 deletion src/tedi/components/content/calendar/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
export { CalendarComponent } from "./calendar.component";
export type { CalendarView, DateFieldMode, DateRange } from "./types";
export type {
CalendarView,
DateFieldMode,
DateRange,
DayPredicate,
DayAvailabilityInput,
MonthPredicate,
YearPredicate,
} from "./types";
export type { Matcher } from "../../../utils/matchers.util";
10 changes: 10 additions & 0 deletions src/tedi/components/content/calendar/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
export type { DateRange } from "../../../utils/date.util";
export type CalendarView = "days" | "months" | "years";
export type DateFieldMode = "single" | "multiple" | "range";

/**
* Whitelist/blacklist of days — an explicit `Date[]` or a predicate. Shared by
* Calendar and the fields that wrap it, so consumers get one type back from the
* entry point rather than three structurally identical copies.
*/
export type DayPredicate = (date: Date) => boolean;
export type DayAvailabilityInput = Date[] | DayPredicate | undefined;
export type MonthPredicate = (month: Date) => boolean;
export type YearPredicate = (year: Date) => boolean;
3 changes: 3 additions & 0 deletions src/tedi/components/form/date-field/date-field.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
[iconDisabled]="!enableCalendarResolved()"
[useNativePicker]="useNativePickerEffective()"
[nativeIsoValue]="nativeIsoValue()"
[nativeMin]="nativeMin()"
[nativeMax]="nativeMax()"
[clearable]="canClear()"
(click)="handleInputClick($event)"
(inputChange)="handleInputChange($event)"
Expand All @@ -41,6 +43,7 @@
class="tedi-date-field__overlay"
role="dialog"
[attr.aria-label]="'date-field.calendar-dialog' | tediTranslate"
[style.max-height.px]="overlayMaxHeight()"
cdkTrapFocus
(keydown)="handleOverlayKeydown($event)"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
}

&__overlay {
overflow-y: auto;
background: var(--card-background-primary);
border-radius: var(--card-radius-rounded);
box-shadow: 0 1px 5px 0 var(--tedi-alpha-20);
Expand Down
50 changes: 50 additions & 0 deletions src/tedi/components/form/date-field/date-field.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,20 @@ describe("DateFieldComponent", () => {
expect(input.type).toBe("date");
});

it("bounds the native input by minDate and maxDate", () => {
const { el } = createField({
useNativePicker: true,
minDate: new Date(2026, 4, 1),
maxDate: new Date(2026, 4, 31),
});
const input = el.querySelector(
"input.tedi-date-input__input",
) as HTMLInputElement;
expect(input.getAttribute("min")).toBe("2026-05-01");
expect(input.getAttribute("max")).toBe("2026-05-31");
expect(input.hasAttribute("step")).toBe(false);
});

it("does NOT switch to native picker when mode is multiple", () => {
const { el } = createField({
useNativePicker: true,
Expand Down Expand Up @@ -662,6 +676,42 @@ describe("DateFieldComponent", () => {
expect(() => component.handleOverlayAttached()).not.toThrow();
});

it("caps the popover to the roomier side of the trigger so a tall calendar scrolls", () => {
const { fixture, component } = createField();
openOverlay(component);
fixture.detectChanges();

jest
.spyOn(component["hostEl"].nativeElement, "getBoundingClientRect")
.mockReturnValue({ top: 600, bottom: 640 } as DOMRect);
const originalHeight = window.innerHeight;
Object.defineProperty(window, "innerHeight", {
value: 800,
configurable: true,
});

try {
component.handleOverlayAttached();
fixture.detectChanges();

// More room above (600 - 8) than below (800 - 640 - 8).
expect(component.overlayMaxHeight()).toBe(592);

const overlay = document.querySelector<HTMLElement>(
".tedi-date-field__overlay",
);
expect(overlay?.style.maxHeight).toBe("592px");
} finally {
Object.defineProperty(window, "innerHeight", {
value: originalHeight,
configurable: true,
});
}

component.overlayOpen.set(false);
fixture.detectChanges();
});

it("moves DOM focus into the calendar when opened via the icon (real flow)", () => {
const { fixture, el, component } = createField();
const iconBtn = el.querySelector(
Expand Down
Loading
Loading