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.
- package/README.md +2 -2
- package/dist/module.json +1 -1
- package/dist/runtime/components/Button.d.vue.ts +3 -2
- package/dist/runtime/components/Button.vue +19 -11
- package/dist/runtime/components/Button.vue.d.ts +3 -2
- package/dist/runtime/components/Calendar.USAGE.md +51 -0
- package/dist/runtime/components/Calendar.vue +254 -87
- package/dist/runtime/components/Canvas/USAGE.md +65 -0
- package/dist/runtime/components/CheckBox.vue +9 -3
- package/dist/runtime/components/CheckboxGroup.vue +7 -1
- package/dist/runtime/components/ControlElement.USAGE.md +69 -0
- package/dist/runtime/components/ControlElement.d.vue.ts +42 -27
- package/dist/runtime/components/ControlElement.vue +28 -9
- package/dist/runtime/components/ControlElement.vue.d.ts +42 -27
- package/dist/runtime/components/Input.USAGE.md +49 -0
- package/dist/runtime/components/Input.vue +13 -3
- package/dist/runtime/components/Modal.USAGE.md +64 -0
- package/dist/runtime/components/NavButton.d.vue.ts +0 -1
- package/dist/runtime/components/NavButton.vue +9 -5
- package/dist/runtime/components/NavButton.vue.d.ts +0 -1
- package/dist/runtime/components/NumberInput/Horizontal.vue +7 -2
- package/dist/runtime/components/NumberInput/Vertical.vue +7 -2
- package/dist/runtime/components/NumberInput/index.d.vue.ts +0 -2
- package/dist/runtime/components/NumberInput/index.vue +9 -7
- package/dist/runtime/components/NumberInput/index.vue.d.ts +0 -2
- package/dist/runtime/components/RadioButton.d.vue.ts +0 -2
- package/dist/runtime/components/RadioButton.vue +9 -4
- package/dist/runtime/components/RadioButton.vue.d.ts +0 -2
- package/dist/runtime/components/Selector.d.vue.ts +1 -0
- package/dist/runtime/components/Selector.vue +10 -4
- package/dist/runtime/components/Selector.vue.d.ts +1 -0
- package/dist/runtime/components/SwitchButton.d.vue.ts +1 -4
- package/dist/runtime/components/SwitchButton.vue +10 -7
- package/dist/runtime/components/SwitchButton.vue.d.ts +1 -4
- package/dist/runtime/components/TaggableSelector.vue +7 -1
- package/dist/runtime/components/Textarea.vue +13 -3
- package/dist/runtime/components/date/Picker.USAGE.md +44 -0
- package/dist/runtime/components/date/Picker.vue +7 -1
- package/dist/runtime/components/date/PickerTrigger.vue +9 -3
- package/dist/runtime/components/date/RangePicker.vue +7 -1
- package/dist/runtime/composables/useFilter.d.ts +91 -0
- package/dist/runtime/composables/useFilter.js +111 -0
- package/dist/runtime/composables/useRovingGrid.d.ts +35 -0
- package/dist/runtime/composables/useRovingGrid.js +115 -0
- package/dist/runtime/i18n/en.json +4 -1
- package/dist/runtime/i18n/uk.json +4 -1
- 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 (
|
|
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/ #
|
|
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
|
@@ -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="
|
|
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
|
-
<
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
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(
|
|
24
|
+
() => startOfMonth(
|
|
25
|
+
parseISO(anchor.value) ?? parseISO(props.selected) ?? /* @__PURE__ */ new Date()
|
|
26
|
+
)
|
|
24
27
|
);
|
|
25
|
-
function
|
|
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
|
|
36
|
-
return Array.from({ length: 7 }, (_,
|
|
37
|
-
const
|
|
38
|
-
|
|
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
|
-
|
|
63
|
+
date
|
|
41
64
|
);
|
|
42
65
|
});
|
|
43
66
|
});
|
|
44
|
-
function resolveMarker(iso) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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:
|
|
82
|
-
inMonth:
|
|
83
|
-
isToday: isSameDay(
|
|
84
|
-
isSelected: !!
|
|
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
|
|
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
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
119
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
318
|
+
width: 22rem;
|
|
161
319
|
}
|
|
162
320
|
|
|
163
321
|
.calendar-nav {
|
|
164
322
|
display: flex;
|
|
165
323
|
align-items: center;
|
|
166
|
-
|
|
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`
|