Source: https://monarchui.dev/components/weekly-hours-editor/

Components · Date, time and scheduling

# WeeklyHoursEditor

Edits weekly availability: days on or off, several intervals a day, copy and overlap checks.

@rookcom/monarch-react/scheduling

Component

## Controlled with validation

## Usage examples

### Uncontrolled, 12-hour clock, week from Sunday

### Read-only

## Purpose

Lets people set the hours they (or a team, or a support desk) are available each week. Needed by: calendar, tickets.

### Use when

- Booking availability, business hours, support or on-call hours.
- Any setting that is "these times on these weekdays".

### Avoid when

- One-off dates (holidays, time off): use a date list next to it.
- Shift rotas that differ week to week: that is a scheduling grid, not a weekly pattern.

## Design principles

- The value is plain data, `{ day, start, end }[]` in minutes, a plain, serialisable shape, so it goes to the API and to TimeGrid `workingHours` unchanged.
- Each day is switched on or off; switching off and on again restores the day’s intervals.
- Times are picked from a list (`step` minutes), never typed free-hand, so there are no parse errors.
- Validation is shown under the day and exported as `validateWeeklyHours`; the product decides whether to block saving.
- The time zone is shown, not assumed: `timeZoneLabel` holds text or the product’s timezone picker.

## API reference

```tsx
import { WeeklyHoursEditor, validateWeeklyHours } from '@rookcom/monarch-react/scheduling';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `WeeklyHoursEditor` | component | client | `.mn-weekly-hours-editor` |
| `validateWeeklyHours` | utility | client | – |

Renders a `<fieldset>` (ref) with the legend, the zone slot and one row per weekday in `weekStartsOn` order (0 = Sunday … 6 = Saturday, as in the date pickers). Interval `day` values are ISO weekdays (1 = Monday … 7 = Sunday), Rook Calendar’s stored shape.

Controlled with `value` + `onValueChange(value, errors)`, or uncontrolled with `defaultValue`. `onValueChange` emits intervals ordered by weekday, each day’s intervals in the order the user arranged them.

`name` adds a hidden input holding the value as JSON, for plain form posts.

`validateWeeklyHours(value)` returns `{ [weekday]: "range" | "order" | "overlap" }` for invalid days.

Times use Monarch NativeSelect (the platform list works well on phones and with 97 options); a separate TimeInput/TimeSelect component is not part of this wave.

Generated from the TypeScript declarations of `packages/react/src/components/scheduling/weekly-hours-editor`.

## Properties

### `WeeklyHoursEditor`

Edits weekly availability: each day switched on or off, one or more time intervals per day, copying a day to the whole week and overlap checks. The value is a plain list of `{ day, start, end }` intervals in minutes.

| Prop | Type | Description |
| --- | --- | --- |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `defaultInterval` | `{ start: number; end: number; }` | The interval a day gets when switched on for the first time. Default 09:00–17:00. |
| `defaultValue` | `WeeklyHoursValue` | Uncontrolled starting value. Default: no hours on any day. |
| `disabled` | `boolean` |  |
| `hourCycle` | `HourCycle` | Force a 12- or 24-hour clock (`'12'` or `'24'`, as in the date pickers); by default the locale decides. |
| `id` | `string` |  |
| `labels` | `WeeklyHoursEditorLabels` |  |
| `legend` | `ReactNode` | Visible group title. Default "Weekly hours". |
| `locale` | `string` | BCP 47 locale for day names and times. |
| `maxIntervals` | `number` | Most intervals per day. Default 6. |
| `name` | `string` | Submits the value as JSON in a hidden input with this name. |
| `onValueChange` | `((value: WeeklyHoursInterval[], errors: Partial<Record<number, WeeklyHoursError>>) => void)` | Called with every change, intervals ordered by ISO weekday, and the current validation result (see `validateWeeklyHours`) so the product can block saving while it is not empty. |
| `readOnly` | `boolean` | Shows the hours as text, without controls. |
| `step` | `number` | Minutes between time options. Default 15. |
| `timeZoneLabel` | `ReactNode` | Slot beside the legend for the zone the hours are in: text such as "Times in Asia/Kolkata", or a timezone picker. |
| `value` | `WeeklyHoursValue` | Controlled value. |
| `weekStartsOn` | `Weekday` | First row: 0 = Sunday … 6 = Saturday, as in MiniMonth and the date pickers. Default 1 (Monday). (Interval `day` values stay ISO 1–7.) |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `readOnly` | `'true'``'false'` | Shows each day’s hours as text ("09:00 – 17:00"), without controls. |
| `disabled` | `'true'``'false'` | Disables the whole fieldset. |
| `step` | `'5'``'10'``'15'``'30'``'60'` | Minutes between time options. Default 15. |
| `hourCycle` | `'12'``'24'` | Force a 12- or 24-hour clock; 24-hour shows "24:00" for the end of the day, 12-hour "Midnight". |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Day on | `data-on (row)` | Switch on, intervals shown, copy button available. |
| Day off | – | "Unavailable" in muted text. |
| Invalid | `data-invalid (row), aria-invalid (selects)` | Danger-coloured message under the day (`role="alert"`), linked to its selects. |
| Read-only | `data-readonly` | Text only. |
| Disabled | – | Native fieldset disabling of every control. |

## Interaction behaviour

- Switching a day on adds `defaultInterval` (09:00–17:00), or restores the intervals it had when switched off.
- "+" adds an interval an hour after the last one (an hour long); "×" removes one. At most `maxIntervals` per day.
- "Copy to all" copies that day’s intervals to every day of the week.
- Errors appear as soon as a day is invalid: times outside the day, an interval ending before it starts, or overlapping intervals.

## Keyboard support

| Keys | Action |
| --- | --- |
| Tab / Shift+Tab | Through each day’s switch, selects, remove/add buttons and copy button, in reading order. |
| Space | Toggles a day’s switch. |
| Arrow keys | Change a time in a focused select (platform behaviour). |
| Enter / Space | Activate the add, remove and copy buttons. |

## Accessibility

Role

group (fieldset with legend); one group per day

Accessible name

The fieldset is named by

legend

; each switch and day group by the weekday; selects "Monday interval 1 start".

- Buttons have full names: "Add Monday interval", "Remove Monday interval 2", "Copy to all days from Monday" (the visible "Copy to all" starts the name).
- Errors use `role="alert"` and are referenced by the day’s selects through `aria-describedby`.
- Day and time names come from Intl in `locale`; all fixed strings are in `labels`.
- Verified with axe in jsdom and in Chromium, Firefox and WebKit on /components/weekly-hours-editor.

## Responsive behaviour

- A container query stacks each day below 34rem: the name and copy button on one line, intervals underneath.
- Interval rows wrap, so two selects and their buttons fit at 320px.

## Theme support

- Built from Monarch Switch, NativeSelect and Button, so every theme and the product accent apply.
- Errors use `--mn-color-danger-strong`.

## Do and don't

### Do

- Show the time zone the hours are in.
- Block saving while `validateWeeklyHours` returns errors, and validate again on the server.
- Pass the workspace’s week start so rows match the calendar.

### Don't

- Do not store times as display strings; keep minutes from midnight.
- Do not put holidays or one-off closures in weekly hours.

## Related components

- [TimeGrid](https://monarchui.dev/components/time-grid/): Shades time outside the same value (`workingHours`).
- [TimezoneSelect](https://monarchui.dev/components/timezone-select/): Goes in the `timeZoneLabel` slot.
- [Switch](https://monarchui.dev/components/switch/): The per-day on/off control.
- [Field](https://monarchui.dev/components/form-field/): NativeSelect provides the time lists.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react` |
| Monarch components | [Button](https://monarchui.dev/components/button/), [Field](https://monarchui.dev/components/form-field/), [Switch](https://monarchui.dev/components/switch/) |

## Testing coverage

- `validateWeeklyHours`: range, order and overlap per day.
- Switching a day on adds the default interval (uncontrolled) and reports it.
- Add, edit and remove intervals.
- Overlap shows an alert linked to the selects with `aria-invalid`.
- Copy to all days.
- Switching a day off and on restores its intervals.
- Controlled mode and the hidden form input.
- Week start order, 12-hour labels and Tab order.
- Read-only text, zone label and disabled fieldset; no axe violations; renders on the server.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/weekly-hours-editor; Cross-browser axe: /components/weekly-hours-editor |

Tests: `packages/react/src/components/scheduling/weekly-hours-editor`

## All example code

### Controlled with validation

```tsx
import { useState } from 'react';
import { WeeklyHoursEditor, validateWeeklyHours, type WeeklyHoursInterval } from '@rookcom/monarch-react/scheduling';

const WEEKDAYS: WeeklyHoursInterval[] = [1, 2, 3, 4, 5].flatMap((day) => [
  { day, start: 540, end: 750 },
  { day, start: 810, end: 1050 },
]);

function ControlledDemo() {
  const [value, setValue] = useState<WeeklyHoursInterval[]>(WEEKDAYS);
  const invalid = Object.keys(validateWeeklyHours(value)).length > 0;
  const hours = value.reduce((sum, r) => sum + Math.max(0, r.end - r.start), 0) / 60;
  return (
    <div style={{ display: 'grid', gap: 8, width: '100%', maxWidth: 640 }}>
      <WeeklyHoursEditor
        locale="en-GB"
        value={value}
        onValueChange={setValue}
        timeZoneLabel="Times in Europe/London"
      />
      <p style={{ margin: 0, fontSize: 13 }} aria-live="polite">
        {invalid ? 'Fix the highlighted days to save.' : `${hours} hours a week`}
      </p>
    </div>
  );
}

<ControlledDemo />
```

### Uncontrolled, 12-hour clock, week from Sunday

```tsx
import { WeeklyHoursEditor } from '@rookcom/monarch-react/scheduling';

<div style={{ width: '100%', maxWidth: 640 }}>
  <WeeklyHoursEditor
    locale="en-US"
    hourCycle="12"
    weekStartsOn={0}
    legend="Support hours"
    defaultValue={[{ day: 1, start: 480, end: 1440 }]}
    step={30}
    name="supportHours"
  />
</div>
```

### Read-only

```tsx
import { WeeklyHoursEditor, type WeeklyHoursInterval } from '@rookcom/monarch-react/scheduling';

const WEEKDAYS: WeeklyHoursInterval[] = [1, 2, 3, 4, 5].flatMap((day) => [
  { day, start: 540, end: 750 },
  { day, start: 810, end: 1050 },
]);

<div style={{ width: '100%', maxWidth: 480 }}>
  <WeeklyHoursEditor
    locale="en-GB"
    legend="Business hours"
    readOnly
    defaultValue={WEEKDAYS}
    timeZoneLabel="Times in Asia/Kolkata"
  />
</div>
```
