orio-ui 1.24.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +2 -2
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Button.d.vue.ts +3 -2
  4. package/dist/runtime/components/Button.vue +19 -11
  5. package/dist/runtime/components/Button.vue.d.ts +3 -2
  6. package/dist/runtime/components/Calendar.USAGE.md +51 -0
  7. package/dist/runtime/components/Calendar.vue +254 -87
  8. package/dist/runtime/components/Canvas/USAGE.md +65 -0
  9. package/dist/runtime/components/CheckBox.vue +9 -3
  10. package/dist/runtime/components/CheckboxGroup.vue +7 -1
  11. package/dist/runtime/components/ControlElement.USAGE.md +69 -0
  12. package/dist/runtime/components/ControlElement.d.vue.ts +42 -27
  13. package/dist/runtime/components/ControlElement.vue +28 -9
  14. package/dist/runtime/components/ControlElement.vue.d.ts +42 -27
  15. package/dist/runtime/components/Input.USAGE.md +49 -0
  16. package/dist/runtime/components/Input.vue +13 -3
  17. package/dist/runtime/components/Modal.USAGE.md +64 -0
  18. package/dist/runtime/components/NavButton.d.vue.ts +0 -1
  19. package/dist/runtime/components/NavButton.vue +9 -5
  20. package/dist/runtime/components/NavButton.vue.d.ts +0 -1
  21. package/dist/runtime/components/NumberInput/Horizontal.vue +7 -2
  22. package/dist/runtime/components/NumberInput/Vertical.vue +7 -2
  23. package/dist/runtime/components/NumberInput/index.d.vue.ts +0 -2
  24. package/dist/runtime/components/NumberInput/index.vue +9 -7
  25. package/dist/runtime/components/NumberInput/index.vue.d.ts +0 -2
  26. package/dist/runtime/components/RadioButton.d.vue.ts +0 -2
  27. package/dist/runtime/components/RadioButton.vue +9 -4
  28. package/dist/runtime/components/RadioButton.vue.d.ts +0 -2
  29. package/dist/runtime/components/Selector.d.vue.ts +1 -0
  30. package/dist/runtime/components/Selector.vue +10 -4
  31. package/dist/runtime/components/Selector.vue.d.ts +1 -0
  32. package/dist/runtime/components/SwitchButton.d.vue.ts +1 -4
  33. package/dist/runtime/components/SwitchButton.vue +10 -7
  34. package/dist/runtime/components/SwitchButton.vue.d.ts +1 -4
  35. package/dist/runtime/components/TaggableSelector.vue +7 -1
  36. package/dist/runtime/components/Textarea.vue +13 -3
  37. package/dist/runtime/components/date/Picker.USAGE.md +44 -0
  38. package/dist/runtime/components/date/Picker.vue +7 -1
  39. package/dist/runtime/components/date/PickerTrigger.vue +9 -3
  40. package/dist/runtime/components/date/RangePicker.vue +7 -1
  41. package/dist/runtime/composables/useFilter.d.ts +91 -0
  42. package/dist/runtime/composables/useFilter.js +111 -0
  43. package/dist/runtime/composables/useRovingGrid.d.ts +35 -0
  44. package/dist/runtime/composables/useRovingGrid.js +115 -0
  45. package/dist/runtime/i18n/en.json +4 -1
  46. package/dist/runtime/i18n/uk.json +4 -1
  47. package/package.json +1 -1
package/README.md CHANGED
@@ -119,7 +119,7 @@ function handleClick() {
119
119
 
120
120
  - **Upload** - File upload component
121
121
 
122
- ### Composables (13)
122
+ ### Composables (15)
123
123
 
124
124
  - **useTheme** - Theme and color mode management
125
125
  - **useModal** - Modal state with animation origin tracking
@@ -195,7 +195,7 @@ orio-ui/
195
195
  ├── src/
196
196
  │ ├── runtime/
197
197
  │ │ ├── components/ # 58 Vue components
198
- │ │ ├── composables/ # 13 composables
198
+ │ │ ├── composables/ # 15 composables
199
199
  │ │ ├── assets/css/ # Theme CSS files
200
200
  │ │ └── utils/ # Icon registry
201
201
  │ └── module.ts # Nuxt Module definition
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": "^3.0.0 || ^4.0.0"
6
6
  },
7
- "version": "1.24.0",
7
+ "version": "1.27.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
@@ -3,13 +3,14 @@ interface Props extends ControlProps {
3
3
  variant?: "primary" | "secondary" | "subdued";
4
4
  icon?: string;
5
5
  loading?: boolean;
6
- disabled?: boolean;
7
6
  }
8
- declare var __VLS_13: {}, __VLS_20: {};
7
+ declare var __VLS_13: {}, __VLS_20: {}, __VLS_22: {};
9
8
  type __VLS_Slots = {} & {
10
9
  icon?: (props: typeof __VLS_13) => any;
11
10
  } & {
12
11
  default?: (props: typeof __VLS_20) => any;
12
+ } & {
13
+ 'icon-right'?: (props: typeof __VLS_22) => any;
13
14
  };
14
15
  declare const __VLS_base: import("vue").DefineComponent<Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
15
16
  click: (event: PointerEvent) => any;
@@ -4,7 +4,6 @@ const props = defineProps({
4
4
  variant: { type: String, required: false, default: "primary" },
5
5
  icon: { type: String, required: false },
6
6
  loading: { type: Boolean, required: false },
7
- disabled: { type: Boolean, required: false },
8
7
  appearance: { type: String, required: false },
9
8
  error: { type: [String, null], required: false },
10
9
  group: { type: Boolean, required: false },
@@ -12,7 +11,13 @@ const props = defineProps({
12
11
  label: { type: String, required: false },
13
12
  layout: { type: String, required: false },
14
13
  size: { type: String, required: false },
15
- fill: { type: Boolean, required: false }
14
+ fill: { type: Boolean, required: false },
15
+ tabindex: { type: [Number, String], required: false },
16
+ focusKey: { type: String, required: false },
17
+ disabled: { type: Boolean, required: false },
18
+ required: { type: Boolean, required: false },
19
+ name: { type: String, required: false },
20
+ ariaLabel: { type: String, required: false }
16
21
  });
17
22
  const { loading, disabled } = toRefs(props);
18
23
  const slots = useSlots();
@@ -39,21 +44,25 @@ function onMouseleave(event) {
39
44
  </script>
40
45
 
41
46
  <template>
42
- <orio-control-element v-bind="props">
47
+ <orio-control-element v-slot="{ control }" v-bind="props">
43
48
  <button
44
- v-bind="$attrs"
49
+ v-bind="{ ...$attrs, ...control }"
45
50
  :class="[variant, 'gradient-hover', { 'icon-only': isIconOnly }]"
46
- :disabled
47
51
  @click="click"
48
52
  @mousedown="onMousedown"
49
53
  @mouseup="onMouseup"
50
54
  @mouseleave="onMouseleave"
51
55
  >
52
56
  <orio-loading-spinner v-if="loading" />
53
- <slot v-else name="icon">
54
- <orio-icon v-if="icon" :name="icon" />
55
- </slot>
56
- <slot />
57
+ <template v-else>
58
+ <slot name="icon">
59
+ <orio-icon v-if="icon" :name="icon" />
60
+ </slot>
61
+
62
+ <slot />
63
+
64
+ <slot name="icon-right" />
65
+ </template>
57
66
  </button>
58
67
  </orio-control-element>
59
68
  </template>
@@ -73,9 +82,8 @@ button {
73
82
  user-select: none;
74
83
  }
75
84
  button.icon-only {
76
- padding: 0;
77
- border-radius: 50%;
78
85
  line-height: 0;
86
+ aspect-ratio: 1;
79
87
  }
80
88
  button:disabled, button:disabled:hover {
81
89
  background-color: var(--color-accent-soft-base);
@@ -3,13 +3,14 @@ interface Props extends ControlProps {
3
3
  variant?: "primary" | "secondary" | "subdued";
4
4
  icon?: string;
5
5
  loading?: boolean;
6
- disabled?: boolean;
7
6
  }
8
- declare var __VLS_13: {}, __VLS_20: {};
7
+ declare var __VLS_13: {}, __VLS_20: {}, __VLS_22: {};
9
8
  type __VLS_Slots = {} & {
10
9
  icon?: (props: typeof __VLS_13) => any;
11
10
  } & {
12
11
  default?: (props: typeof __VLS_20) => any;
12
+ } & {
13
+ 'icon-right'?: (props: typeof __VLS_22) => any;
13
14
  };
14
15
  declare const __VLS_base: import("vue").DefineComponent<Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
15
16
  click: (event: PointerEvent) => any;
@@ -0,0 +1,51 @@
1
+ # Calendar — agent-only invariants
2
+
3
+ `<orio-calendar>` is the month-grid primitive. `date/Picker.vue` and
4
+ `date/RangePicker.vue` compose it inside a popover.
5
+
6
+ ## Invariants
7
+
8
+ - **All dates are ISO `YYYY-MM-DD` strings** at the API boundary
9
+ (`selected`, `markers.start`/`end`, `getMarker`, `isDisabled`, `@select`,
10
+ `@dayEnter`). Never pass `Date` objects.
11
+ - **Two reactive states**:
12
+ - `selected` (prop) — the picked day. Calendar does not own it; emit
13
+ `@select` and let the parent update its v-model.
14
+ - `anchor` (v-model:anchor) — the visible month. Calendar owns this when
15
+ uncontrolled, derived from `selected` if not provided. Pass it as
16
+ `v-model:anchor` if you need cross-component sync (RangePicker uses
17
+ this to keep two months in lockstep).
18
+ - **Keyboard a11y via `useRovingGrid`.** Arrow keys, Home/End, PageUp/Down
19
+ navigate. Only one day cell is in the tab order at a time. Do not add
20
+ manual `tabindex` to day cells from outside.
21
+ - **42-cell grid (6 rows × 7 cols).** Leading/trailing days from neighbour
22
+ months are rendered with `inMonth: false`. Selection and disabled checks
23
+ apply to them too — `isDisabled` is called for every cell.
24
+ - **Markers are matched in reverse order** — the last marker in the array
25
+ wins on overlap. `getMarker` takes precedence over `markers[]` when both
26
+ are provided.
27
+ - **`weekStartsOn`** is `0` (Sunday) or `1` (Monday, default). Weekday
28
+ header labels are derived via `Intl.DateTimeFormat` in the active i18n
29
+ locale.
30
+
31
+ ## Gotchas
32
+
33
+ - Disabled-day logic should be reactive — `isDisabled` is called inside a
34
+ computed. Wrap any external state it reads in `computed`/`ref` or it will
35
+ not re-render on change.
36
+ - The Calendar emits `@dayEnter` on hover/keyboard-focus, **not** on
37
+ selection. Use for range-picking previews; selection is only `@select`.
38
+ - i18n keys used: `calendar.previousYear`, `calendar.previousMonth`,
39
+ `calendar.nextMonth`, `calendar.nextYear`. Override these if you ship a
40
+ locale not bundled in `runtime/i18n/`.
41
+
42
+ ## Quick reference
43
+
44
+ ```vue
45
+ <orio-calendar
46
+ :selected="iso"
47
+ :markers="[{ variant: 'accent', start: '2026-06-01', end: '2026-06-07' }]"
48
+ :is-disabled="(iso) => iso < todayIso"
49
+ @select="iso = $event"
50
+ />
51
+ ```
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
- import { computed } from "vue";
2
+ import { computed, ref, useId, watch } from "vue";
3
3
  import { useI18n } from "vue-i18n";
4
+ import { useRovingGrid } from "../composables/useRovingGrid";
4
5
  import {
5
6
  addMonths,
6
7
  formatISO,
@@ -20,129 +21,286 @@ const emit = defineEmits(["select", "dayEnter"]);
20
21
  const { locale, t } = useI18n();
21
22
  const today = /* @__PURE__ */ new Date();
22
23
  const visibleMonth = computed(
23
- () => startOfMonth(parseISO(anchor.value) ?? /* @__PURE__ */ new Date())
24
+ () => startOfMonth(
25
+ parseISO(anchor.value) ?? parseISO(props.selected) ?? /* @__PURE__ */ new Date()
26
+ )
24
27
  );
25
- function shift(delta) {
28
+ function shiftMonth(delta) {
26
29
  anchor.value = formatISO(addMonths(visibleMonth.value, delta));
27
30
  }
31
+ function shiftYear(delta) {
32
+ const target = new Date(visibleMonth.value);
33
+ target.setFullYear(target.getFullYear() + delta);
34
+ anchor.value = formatISO(startOfMonth(target));
35
+ }
28
36
  const monthLabel = computed(
29
37
  () => new Intl.DateTimeFormat(locale.value, {
30
38
  month: "long",
31
39
  year: "numeric"
32
40
  }).format(visibleMonth.value)
33
41
  );
42
+ const titleId = useId();
43
+ const dayLabelFormat = computed(
44
+ () => new Intl.DateTimeFormat(locale.value, {
45
+ weekday: "long",
46
+ day: "numeric",
47
+ month: "long",
48
+ year: "numeric"
49
+ })
50
+ );
51
+ function fullDateLabel(iso) {
52
+ const date = parseISO(iso);
53
+ return date ? dayLabelFormat.value.format(date) : iso;
54
+ }
34
55
  const weekdayLabels = computed(() => {
35
- const sunday = new Date(2024, 0, 7);
36
- return Array.from({ length: 7 }, (_, i) => {
37
- const d = new Date(sunday);
38
- d.setDate(sunday.getDate() + (i + props.weekStartsOn) % 7);
56
+ const tuesday = new Date(1998, 6, 14);
57
+ return Array.from({ length: 7 }, (_, position) => {
58
+ const date = new Date(tuesday);
59
+ date.setDate(
60
+ tuesday.getDate() + (position + props.weekStartsOn - 2 + 7) % 7
61
+ );
39
62
  return new Intl.DateTimeFormat(locale.value, { weekday: "short" }).format(
40
- d
63
+ date
41
64
  );
42
65
  });
43
66
  });
44
- function resolveMarker(iso) {
45
- if (props.getMarker) {
46
- const m = props.getMarker(iso);
47
- if (m) {
48
- return {
49
- variant: m.variant,
50
- isStart: iso === m.start,
51
- isEnd: iso === m.end
52
- };
53
- }
54
- }
55
- for (let i = props.markers.length - 1; i >= 0; i--) {
56
- const m = props.markers[i];
57
- if (iso >= m.start && iso <= m.end) {
58
- return {
59
- variant: m.variant,
60
- isStart: iso === m.start,
61
- isEnd: iso === m.end
62
- };
63
- }
64
- }
65
- return null;
67
+ function resolveMarker(iso, reversedMarkers) {
68
+ const matched = props.getMarker?.(iso) ?? reversedMarkers.find(
69
+ (marker) => iso >= marker.start && iso <= marker.end
70
+ ) ?? null;
71
+ if (!matched) return null;
72
+ return {
73
+ variant: matched.variant,
74
+ isStart: iso === matched.start,
75
+ isEnd: iso === matched.end
76
+ };
66
77
  }
67
78
  const days = computed(() => {
68
- const first = visibleMonth.value;
69
- const startWeekday = first.getDay();
70
- const offset = (startWeekday - props.weekStartsOn + 7) % 7;
71
- const gridStart = new Date(first);
72
- gridStart.setDate(gridStart.getDate() - offset);
73
- const sel = parseISO(props.selected);
74
- const result = [];
75
- for (let i = 0; i < 42; i++) {
76
- const d = new Date(gridStart);
77
- d.setDate(gridStart.getDate() + i);
78
- const iso = formatISO(d);
79
- result.push({
79
+ const firstOfMonth = visibleMonth.value;
80
+ const leadingOffset = (firstOfMonth.getDay() - props.weekStartsOn + 7) % 7;
81
+ const gridStart = new Date(firstOfMonth);
82
+ gridStart.setDate(gridStart.getDate() - leadingOffset);
83
+ const selectedDate = parseISO(props.selected);
84
+ const reversedMarkers = [...props.markers].reverse();
85
+ return Array.from({ length: 42 }, (_, dayOffset) => {
86
+ const date = new Date(gridStart);
87
+ date.setDate(gridStart.getDate() + dayOffset);
88
+ const iso = formatISO(date);
89
+ return {
80
90
  iso,
81
- label: d.getDate(),
82
- inMonth: d.getMonth() === first.getMonth(),
83
- isToday: isSameDay(d, today),
84
- isSelected: !!sel && isSameDay(d, sel),
91
+ label: date.getDate(),
92
+ inMonth: date.getMonth() === firstOfMonth.getMonth(),
93
+ isToday: isSameDay(date, today),
94
+ isSelected: !!selectedDate && isSameDay(date, selectedDate),
85
95
  isDisabled: props.isDisabled?.(iso) ?? false,
86
- marker: resolveMarker(iso)
87
- });
96
+ marker: resolveMarker(iso, reversedMarkers)
97
+ };
98
+ });
99
+ });
100
+ const weeks = computed(
101
+ () => Array.from(
102
+ { length: 6 },
103
+ (_, weekIndex) => days.value.slice(weekIndex * 7, weekIndex * 7 + 7)
104
+ )
105
+ );
106
+ function isInVisibleMonth(date) {
107
+ return date.getMonth() === visibleMonth.value.getMonth() && date.getFullYear() === visibleMonth.value.getFullYear();
108
+ }
109
+ function initialActiveISO() {
110
+ const selectedDate = parseISO(props.selected);
111
+ if (selectedDate && isInVisibleMonth(selectedDate)) {
112
+ return formatISO(selectedDate);
113
+ }
114
+ if (isInVisibleMonth(today)) return formatISO(today);
115
+ return formatISO(visibleMonth.value);
116
+ }
117
+ const navButtons = computed(() => [
118
+ {
119
+ key: "year-prev",
120
+ ariaLabel: t("calendar.previousYear"),
121
+ icon: "chevron-left",
122
+ size: "md",
123
+ action: () => shiftYear(-1)
124
+ },
125
+ {
126
+ key: "month-prev",
127
+ ariaLabel: t("calendar.previousMonth"),
128
+ icon: "chevron-left",
129
+ size: "sm",
130
+ action: () => shiftMonth(-1)
131
+ },
132
+ {
133
+ key: "month-next",
134
+ ariaLabel: t("calendar.nextMonth"),
135
+ icon: "chevron-right",
136
+ size: "sm",
137
+ action: () => shiftMonth(1)
138
+ },
139
+ {
140
+ key: "year-next",
141
+ ariaLabel: t("calendar.nextYear"),
142
+ icon: "chevron-right",
143
+ size: "md",
144
+ action: () => shiftYear(1)
145
+ }
146
+ ]);
147
+ const navRows = computed(() => [navButtons.value]);
148
+ const navRef = ref(null);
149
+ const {
150
+ tabindexFor: tabindexForNav,
151
+ setActive: setActiveNav,
152
+ onKeydown: onNavKeydown
153
+ } = useRovingGrid({
154
+ rows: navRows,
155
+ gridRef: navRef,
156
+ getKey: (button) => button.key,
157
+ initial: () => "month-prev",
158
+ onActivate: (button) => button.action()
159
+ });
160
+ function onNavButtonClick(button) {
161
+ setActiveNav(button.key, false);
162
+ button.action();
163
+ }
164
+ const ARROW_DAY_DELTA = {
165
+ up: -7,
166
+ down: 7,
167
+ left: -1,
168
+ right: 1
169
+ };
170
+ const gridRef = ref(null);
171
+ const { activeKey, setActive, tabindexFor, onKeydown } = useRovingGrid({
172
+ rows: weeks,
173
+ gridRef,
174
+ getKey: (day) => day.iso,
175
+ initial: initialActiveISO,
176
+ isNavigable: (day) => !day.isDisabled,
177
+ onActivate(day) {
178
+ if (day.isDisabled) return;
179
+ emit("select", day.iso);
180
+ },
181
+ onArrowOverflow(direction, currentKey) {
182
+ const date = parseISO(currentKey);
183
+ if (!date) return null;
184
+ const stepDays = ARROW_DAY_DELTA[direction];
185
+ for (let attempt = 0; attempt < 750; attempt++) {
186
+ date.setDate(date.getDate() + stepDays);
187
+ const iso = formatISO(date);
188
+ if (!props.isDisabled?.(iso)) return iso;
189
+ }
190
+ return null;
191
+ },
192
+ onPage(direction, bigJump, currentKey) {
193
+ const date = parseISO(currentKey);
194
+ if (!date) return null;
195
+ const monthDelta = (direction === "down" ? 1 : -1) * (bigJump ? 12 : 1);
196
+ const target = new Date(
197
+ date.getFullYear(),
198
+ date.getMonth() + monthDelta,
199
+ 1
200
+ );
201
+ const lastDayOfTargetMonth = new Date(
202
+ target.getFullYear(),
203
+ target.getMonth() + 1,
204
+ 0
205
+ ).getDate();
206
+ target.setDate(Math.min(date.getDate(), lastDayOfTargetMonth));
207
+ return formatISO(target);
208
+ }
209
+ });
210
+ watch(activeKey, (iso) => {
211
+ const date = parseISO(iso);
212
+ if (date && !isInVisibleMonth(date)) {
213
+ anchor.value = formatISO(startOfMonth(date));
88
214
  }
89
- return result;
90
215
  });
91
- function onSelect(day) {
216
+ function onDayClick(day) {
92
217
  if (day.isDisabled) return;
218
+ setActive(day.iso, false);
93
219
  emit("select", day.iso);
94
220
  }
95
- function onEnter(day) {
221
+ function onDayMouseenter(day) {
96
222
  emit("dayEnter", day.iso);
97
223
  }
98
224
  </script>
99
225
 
100
226
  <template>
101
227
  <div class="calendar">
102
- <div class="calendar-nav">
103
- <orio-button
104
- variant="subdued"
105
- icon="chevron-left"
106
- :aria-label="t('calendar.previousMonth')"
107
- @click="shift(-1)"
108
- />
109
- <span class="calendar-month-title">{{ monthLabel }}</span>
110
- <orio-button
111
- variant="subdued"
112
- icon="chevron-right"
113
- :aria-label="t('calendar.nextMonth')"
114
- @click="shift(1)"
115
- />
228
+ <div
229
+ ref="navRef"
230
+ class="calendar-nav"
231
+ role="toolbar"
232
+ aria-orientation="horizontal"
233
+ :aria-label="t('calendar.navigation')"
234
+ @keydown="onNavKeydown"
235
+ >
236
+ <template v-for="(button, position) in navButtons" :key="button.key">
237
+ <orio-button
238
+ variant="subdued"
239
+ :icon="button.icon"
240
+ :size="button.size"
241
+ :focus-key="button.key"
242
+ :tabindex="tabindexForNav(button.key)"
243
+ :aria-label="button.ariaLabel"
244
+ @click="onNavButtonClick(button)"
245
+ />
246
+ <span v-if="position === 1" :id="titleId" class="calendar-month-title">
247
+ {{ monthLabel }}
248
+ </span>
249
+ </template>
116
250
  </div>
117
251
  <div class="calendar-weekdays">
118
- <span v-for="w in weekdayLabels" :key="w" class="calendar-weekday">
119
- {{ w }}
252
+ <span
253
+ v-for="weekday in weekdayLabels"
254
+ :key="weekday"
255
+ class="calendar-weekday"
256
+ >
257
+ {{ weekday }}
120
258
  </span>
121
259
  </div>
122
- <div class="calendar-grid">
123
- <button
124
- v-for="day in days"
125
- :key="day.iso"
126
- type="button"
127
- class="calendar-day"
128
- :class="{
260
+ <div
261
+ ref="gridRef"
262
+ class="calendar-grid"
263
+ role="grid"
264
+ :aria-labelledby="titleId"
265
+ @keydown="onKeydown"
266
+ >
267
+ <div
268
+ v-for="(week, weekIndex) in weeks"
269
+ :key="weekIndex"
270
+ role="row"
271
+ class="calendar-week"
272
+ >
273
+ <button
274
+ v-for="day in week"
275
+ :key="day.iso"
276
+ type="button"
277
+ role="gridcell"
278
+ class="calendar-day"
279
+ :class="{
129
280
  'out-of-month': !day.inMonth,
130
281
  today: day.isToday,
131
282
  selected: day.isSelected,
132
283
  'has-marker': !!day.marker,
133
284
  [`marker-${day.marker?.variant}`]: !!day.marker,
134
285
  'marker-start': day.marker?.isStart,
135
- 'marker-end': day.marker?.isEnd
286
+ 'marker-end': day.marker?.isEnd,
287
+ active: day.iso === activeKey
136
288
  }"
137
- :disabled="day.isDisabled"
138
- @click="onSelect(day)"
139
- @mouseenter="onEnter(day)"
140
- >
141
- <orio-badge v-if="day.isSelected" pill variant="primary">
142
- {{ day.label }}
143
- </orio-badge>
144
- <template v-else>{{ day.label }}</template>
145
- </button>
289
+ :focus-key="day.iso"
290
+ :tabindex="tabindexFor(day.iso)"
291
+ :aria-selected="day.isSelected"
292
+ :aria-current="day.isToday ? 'date' : void 0"
293
+ :aria-label="fullDateLabel(day.iso)"
294
+ :disabled="day.isDisabled"
295
+ @click="onDayClick(day)"
296
+ @mouseenter="onDayMouseenter(day)"
297
+ >
298
+ <orio-badge v-if="day.isSelected" pill variant="primary">
299
+ {{ day.label }}
300
+ </orio-badge>
301
+ <template v-else>{{ day.label }}</template>
302
+ </button>
303
+ </div>
146
304
  </div>
147
305
  </div>
148
306
  </template>
@@ -157,18 +315,19 @@ function onEnter(day) {
157
315
  user-select: none;
158
316
  font-size: var(--control-font-size, var(--font-md));
159
317
  color: var(--color-text);
160
- min-width: 17rem;
318
+ width: 22rem;
161
319
  }
162
320
 
163
321
  .calendar-nav {
164
322
  display: flex;
165
323
  align-items: center;
166
- justify-content: space-between;
167
- gap: 0.5rem;
324
+ gap: 0.25rem;
168
325
  margin-bottom: 0.5rem;
169
326
  }
170
327
 
171
328
  .calendar-month-title {
329
+ flex: 1;
330
+ text-align: center;
172
331
  font-weight: 600;
173
332
  text-transform: capitalize;
174
333
  }
@@ -179,6 +338,10 @@ function onEnter(day) {
179
338
  grid-template-columns: repeat(7, 1fr);
180
339
  }
181
340
 
341
+ .calendar-week {
342
+ display: contents;
343
+ }
344
+
182
345
  .calendar-weekday {
183
346
  text-align: center;
184
347
  font-size: var(--font-xs);
@@ -203,6 +366,10 @@ function onEnter(day) {
203
366
  .calendar-day:hover:not(:disabled):not(.has-marker):not(.selected) {
204
367
  background-color: var(--color-surface);
205
368
  }
369
+ .calendar-day:focus-visible {
370
+ outline: 2px solid var(--color-accent);
371
+ outline-offset: -2px;
372
+ }
206
373
  .calendar-day.out-of-month {
207
374
  color: var(--color-muted);
208
375
  opacity: 0.45;
@@ -0,0 +1,65 @@
1
+ # Canvas — agent-only invariants
2
+
3
+ Read this before integrating `<orio-canvas>` into a consumer app. Public API
4
+ lives in `docs/components/canvas/`.
5
+
6
+ ## Invariants
7
+
8
+ - **`name` prop is required.** It registers this canvas instance in the
9
+ module-level `canvasRegistry` (`./registry.ts`), which is how detached
10
+ toolbars find it. Without a `name`, a toolbar rendered outside the canvas
11
+ subtree cannot bind to it.
12
+ - **Detached toolbar uses `canvas="<name>"`, not `provide/inject`.** Render
13
+ `<orio-canvas-toolbar canvas="editor" />` anywhere in the app — even in a
14
+ sibling subtree — and it resolves the context via the registry. Toolbars
15
+ nested as children of `<orio-canvas>` resolve via `useCanvasContext()`
16
+ inject and do not need the prop.
17
+ - **Tools are pluggable, nothing ships by default.** Pass `:tools="[drawTool(),
18
+ textTool(), ...]"`. There are no implicit defaults.
19
+ - **Tool factories are functions, not singletons.** Call `drawTool()` per
20
+ canvas instance — closure state (e.g. the in-progress stroke id) must not be
21
+ shared across canvases on the same page.
22
+ - **`v-model:nodes` is the persistence boundary.** Serialize the array to JSON
23
+ for save/load. Unknown node types (tools not yet registered on hydrate) are
24
+ silently skipped on render — register the tool, then re-render.
25
+ - **`frozen: true` nodes** are protected from `eraseTool`, `moveTool`,
26
+ `highlightTool` and future selection tools. Drawing/text passes ignore the
27
+ flag.
28
+ - **`setup(api)` runs once on mount** with the full tool API. Use it to seed
29
+ initial nodes (frozen background images, watermark, etc.). It can add node
30
+ types whose tools are not in the user-facing `tools` prop — useful for
31
+ display-only nodes.
32
+
33
+ ## Gotchas
34
+
35
+ - Tool `id` doubles as the node `type`. Two tools with the same id collide.
36
+ - `setup` may be async; nodes added inside resolve before the first render.
37
+ - Undo snapshot is finalized on `pointerUp` for `drawTool` — mid-stroke is not
38
+ a history step. If you script node creation outside a tool, call
39
+ `requestRender()` and rely on the next user action to checkpoint.
40
+ - Custom fonts: no bundled loader. Caller is responsible for `document.fonts`
41
+ / `FontFace` loading before `textTool` renders that font.
42
+
43
+ ## Quick reference
44
+
45
+ ```vue
46
+ <orio-canvas
47
+ name="editor"
48
+ v-model:nodes="nodes"
49
+ :tools="[drawTool(), textTool(), eraseTool(), undoTool()]"
50
+ :width="800"
51
+ :height="500"
52
+ :setup="(api) => api.addNode({ type: 'bg', frozen: true, ... })"
53
+ />
54
+
55
+ <!-- elsewhere in the app -->
56
+ <orio-canvas-toolbar canvas="editor" />
57
+ ```
58
+
59
+ ## Where things live
60
+
61
+ - Types & `defineCanvasTool` → `./types.ts`
62
+ - Context (`provide`/`inject`, `useCanvasContext`) → `./context.ts`
63
+ - Registry (module-level Map for detached toolbars) → `./registry.ts`
64
+ - Built-in tools → `./tools/*.ts`
65
+ - Full spec → `./REQUIREMENTS.md`