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
149 changes: 134 additions & 15 deletions .claude/skills/contributing/references/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` / `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<T>` (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<boolean>) => 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
<tedi-time-field [useNativePicker]="{ xs: true, md: false }" />
<tedi-time-field [useNativePicker]="true" />
```

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<LinkInputs> {
variant = input<LinkVariant>("default");
size = input<LinkSize>("default");
underline = input<boolean>(true);

xs = input<LinkInputs>();
sm = input<LinkInputs>();
md = input<LinkInputs>();
lg = input<LinkInputs>();
xl = input<LinkInputs>();
xxl = input<LinkInputs>();

private breakpointService = inject(BreakpointService);
breakpointInputs = computed(() =>
this.breakpointService.getBreakpointInputs<LinkInputs>({
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
<tedi-link [md]="{ variant: 'inverted', size: 'small' }">Read more</tedi-link>
```

### 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<Props>` on the whole component? | B |
| React equivalent uses `BreakpointInput<T>` 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<T>"` 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<boolean> = false` — ... Accepts a breakpoint object, e.g. `{ xs: true, md: false }`
```

## Naming Conventions

| Item | Convention | Example |
Expand Down Expand Up @@ -270,28 +384,33 @@ export const Default: StoryObj<ComponentNameComponent> = {
};

export const WithReactiveForms: StoryObj<ComponentNameComponent> = {
decorators: [
moduleMetadata({
imports: [MyControlComponent, ReactiveFormsModule, AlertComponent, TextComponent],
}),
],
render: () => ({
props: { control: new FormControl('') },
template: `
<tedi-my-control [formControl]="control" />
<tedi-alert type="info" [showClose]="false">
<pre tedi-text modifiers="small" style="margin: 0;">{{ {
render: () => {
const control = new FormControl('');

return {
props: { control },
template: `
<tedi-row cols="1" [gapY]="3">
<tedi-col>
<tedi-my-control [formControl]="control" />
</tedi-col>
<tedi-col>
<tedi-alert type="info" [showClose]="false">
<pre tedi-text modifiers="small">{{ {
value: control.value,
touched: control.touched,
dirty: control.dirty
} | json }}</pre>
</tedi-alert>
`,
}),
</tedi-alert>
</tedi-col>
</tedi-row>
`,
};
},
};
```

> **Note:** Always display reactive form state using a `<tedi-alert type="info">` with a `<pre tedi-text modifiers="small">` 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 `<tedi-alert type="info">` with a `<pre tedi-text modifiers="small">` 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:
Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/contributing/references/new-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` or `BreakpointInput<T>`. 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.

Expand Down
7 changes: 7 additions & 0 deletions setup-jest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
87 changes: 81 additions & 6 deletions skills/tedi-angular/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,72 @@ statusControl = new FormControl<string | null>(null);
<tedi-date-picker [formControl]="dateControl" [showWeekNumbers]="true" />
```

### 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 (`<input type="time">`), `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
<tedi-form-field>
<label tedi-label for="time">Time</label>
<tedi-time-field inputId="time" [formControl]="timeControl" pickerTrigger="input" />
</tedi-form-field>

<!-- Custom scroll picker on desktop, OS picker below md -->
<tedi-time-field
inputId="time"
pickerVariant="scroll"
useNativePicker="md"
/>

<!-- Modal below md, fullscreen below sm -->
<tedi-time-field inputId="time" modal="md" fullscreen="sm" />
```

### 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
<tedi-time-picker [(value)]="time" variant="scroll" [minuteStep]="5" />

<!-- Inline picker rendered with a border -->
<tedi-time-picker [(value)]="time" variant="slots" [timeSlots]="['09:00','10:00','11:00']" [border]="true" />
```

### Select
**Selector:** `tedi-select`
**Inputs:**
Expand Down Expand Up @@ -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));
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions skills/tedi-angular/references/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[]` |

Expand Down
1 change: 0 additions & 1 deletion tedi/components/form/filter/filter.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ export interface FilterOption {
DropdownItemValueLabelComponent,
FormFieldComponent,
TextFieldComponent,
FilterContentDirective,
],
templateUrl: "./filter.component.html",
styleUrl: "./filter.component.scss",
Expand Down
2 changes: 1 addition & 1 deletion tedi/components/form/form-field/form-field.component.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<ng-content select="label[tedi-label]"></ng-content>

<div [ngClass]="inputClasses()">
<ng-content select="input[tedi-text-field]"></ng-content>
<ng-content select="input[tedi-text-field], tedi-time-field"></ng-content>

@if (showClearButton()) {
<div class="tedi-form-field__buttons">
Expand Down
4 changes: 3 additions & 1 deletion tedi/components/form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
1 change: 1 addition & 0 deletions tedi/components/form/time-field/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./time-field.component";
Loading
Loading