@birdycom/omniaui-vue 0.11.0 → 0.11.2
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/LICENSE +21 -21
- package/README.md +49 -49
- package/dist/omniaui-vue.js.map +1 -1
- package/package.json +69 -69
- package/src/blocks/OcBlockAboutTimeline.vue +44 -44
- package/src/blocks/OcBlockAppHeader.vue +36 -36
- package/src/blocks/OcBlockAppShell.vue +29 -29
- package/src/blocks/OcBlockAuth.vue +24 -24
- package/src/blocks/OcBlockAuthSplit.vue +47 -47
- package/src/blocks/OcBlockContactGrid.vue +53 -53
- package/src/blocks/OcBlockCta.vue +12 -12
- package/src/blocks/OcBlockDataTable.vue +27 -27
- package/src/blocks/OcBlockFaq.vue +49 -49
- package/src/blocks/OcBlockFeatures.vue +23 -23
- package/src/blocks/OcBlockFooter.vue +25 -25
- package/src/blocks/OcBlockFormPage.vue +24 -24
- package/src/blocks/OcBlockHero.vue +13 -13
- package/src/blocks/OcBlockHeroCode.vue +42 -42
- package/src/blocks/OcBlockHeroOrbit.vue +69 -69
- package/src/blocks/OcBlockHeroSplit.vue +15 -15
- package/src/blocks/OcBlockModuleHeader.vue +21 -21
- package/src/blocks/OcBlockPageHero.vue +13 -13
- package/src/blocks/OcBlockPortfolioGrid.vue +63 -63
- package/src/blocks/OcBlockPricing.vue +39 -39
- package/src/blocks/OcBlockProcess.vue +39 -39
- package/src/blocks/OcBlockProductGrid.vue +41 -41
- package/src/blocks/OcBlockProfileHeader.vue +50 -50
- package/src/blocks/OcBlockShowcase.vue +43 -43
- package/src/blocks/OcBlockSiteFooter.vue +54 -54
- package/src/blocks/OcBlockSiteHeader.vue +34 -34
- package/src/blocks/OcBlockSpotlight.vue +47 -47
- package/src/blocks/OcBlockStats.vue +17 -17
- package/src/blocks/OcBlockStatsCounter.vue +20 -20
- package/src/blocks/OcBlockTeamList.vue +53 -53
- package/src/blocks/OcBlockTwoFactor.vue +61 -61
- package/src/blocks/OcBlockWizardDialog.vue +67 -67
- package/src/components/OcAccordion.vue +12 -12
- package/src/components/OcAccordionAccent.vue +27 -27
- package/src/components/OcAccordionItem.vue +19 -19
- package/src/components/OcAffixInput.vue +40 -40
- package/src/components/OcAlert.vue +17 -17
- package/src/components/OcAlertDialog.vue +66 -66
- package/src/components/OcAlphaTabs.vue +54 -54
- package/src/components/OcAspectRatio.vue +21 -21
- package/src/components/OcAttachment.vue +59 -59
- package/src/components/OcAttachmentImage.vue +13 -13
- package/src/components/OcAttachmentList.vue +31 -31
- package/src/components/OcAttachmentThumb.vue +14 -14
- package/src/components/OcAvatar.vue +18 -18
- package/src/components/OcAvatarGroup.vue +7 -7
- package/src/components/OcBackLink.vue +8 -8
- package/src/components/OcBadge.vue +13 -13
- package/src/components/OcBarcodeInput.vue +51 -51
- package/src/components/OcBreadcrumb.vue +22 -22
- package/src/components/OcBrowserFrame.vue +33 -33
- package/src/components/OcBusyOverlay.vue +32 -32
- package/src/components/OcButton.vue +47 -47
- package/src/components/OcButtonGroup.vue +10 -10
- package/src/components/OcButtonGroupText.vue +7 -7
- package/src/components/OcCard.vue +20 -20
- package/src/components/OcChartLine.vue +80 -80
- package/src/components/OcChatComposer.vue +42 -42
- package/src/components/OcCheckbox.vue +26 -26
- package/src/components/OcChecklist.vue +29 -29
- package/src/components/OcCollapsible.vue +19 -19
- package/src/components/OcColorField.vue +15 -15
- package/src/components/OcColorSelect.vue +33 -33
- package/src/components/OcColumnMenu.vue +50 -50
- package/src/components/OcCommand.vue +119 -119
- package/src/components/OcConfirmDialog.vue +65 -65
- package/src/components/OcCookieConsent.vue +119 -119
- package/src/components/OcCopyField.vue +37 -37
- package/src/components/OcCounter.vue +15 -15
- package/src/components/OcDataTable.vue +144 -144
- package/src/components/OcDefinitionList.vue +35 -35
- package/src/components/OcDropdown.vue +23 -23
- package/src/components/OcDropdownItem.vue +9 -9
- package/src/components/OcDropdownLabel.vue +7 -7
- package/src/components/OcDropdownSeparator.vue +7 -7
- package/src/components/OcDropzone.vue +36 -36
- package/src/components/OcEmojiPicker.vue +45 -45
- package/src/components/OcEmptyState.vue +13 -13
- package/src/components/OcEntityCard.vue +47 -47
- package/src/components/OcFactList.vue +37 -37
- package/src/components/OcFilterCounts.vue +38 -38
- package/src/components/OcFlipCard.vue +36 -36
- package/src/components/OcFormField.vue +36 -36
- package/src/components/OcGreeting.vue +52 -52
- package/src/components/OcHeading.vue +19 -19
- package/src/components/OcHoverCard.vue +25 -25
- package/src/components/OcImageZoom.vue +39 -39
- package/src/components/OcInput.vue +9 -9
- package/src/components/OcInputGroup.vue +12 -12
- package/src/components/OcItem.vue +40 -40
- package/src/components/OcItemGroup.vue +7 -7
- package/src/components/OcItemSeparator.vue +7 -7
- package/src/components/OcKbd.vue +7 -7
- package/src/components/OcKbdGroup.vue +15 -15
- package/src/components/OcLabel.vue +10 -10
- package/src/components/OcLanguageSelect.vue +40 -40
- package/src/components/OcLauncherCard.vue +34 -34
- package/src/components/OcLightbox.vue +63 -63
- package/src/components/OcLinkArrow.vue +25 -25
- package/src/components/OcListToolbar.vue +44 -44
- package/src/components/OcMarquee.vue +36 -36
- package/src/components/OcMediaCard.vue +28 -28
- package/src/components/OcMediaRow.vue +28 -28
- package/src/components/OcMediaThumb.vue +38 -38
- package/src/components/OcMessage.vue +91 -91
- package/src/components/OcMessageList.vue +43 -43
- package/src/components/OcMessageThread.vue +155 -155
- package/src/components/OcModal.vue +42 -42
- package/src/components/OcNavmenu.vue +18 -18
- package/src/components/OcNavmenuList.vue +38 -38
- package/src/components/OcNextTeaser.vue +25 -25
- package/src/components/OcNumberInput.vue +47 -47
- package/src/components/OcOtpInput.vue +34 -34
- package/src/components/OcPageHeader.vue +34 -34
- package/src/components/OcPagination.vue +33 -33
- package/src/components/OcPasswordInput.vue +27 -27
- package/src/components/OcPasswordRequirements.vue +28 -28
- package/src/components/OcPhoneInput.vue +72 -72
- package/src/components/OcPopover.vue +25 -25
- package/src/components/OcPriceCard.vue +69 -69
- package/src/components/OcProductMark.vue +31 -31
- package/src/components/OcProgress.vue +21 -21
- package/src/components/OcProjectCard.vue +36 -36
- package/src/components/OcProse.vue +57 -57
- package/src/components/OcRadio.vue +40 -40
- package/src/components/OcRadioGroup.vue +32 -32
- package/src/components/OcRecoveryCodes.vue +55 -55
- package/src/components/OcScrollArea.vue +15 -15
- package/src/components/OcSegmented.vue +28 -28
- package/src/components/OcSelect.vue +21 -21
- package/src/components/OcSeparator.vue +18 -18
- package/src/components/OcSeverityCard.vue +46 -46
- package/src/components/OcSheet.vue +49 -49
- package/src/components/OcSidebarCollapsible.vue +19 -19
- package/src/components/OcSidebarGroup.vue +11 -11
- package/src/components/OcSidebarMenu.vue +7 -7
- package/src/components/OcSidebarMenuButton.vue +31 -31
- package/src/components/OcSidebarSeparator.vue +7 -7
- package/src/components/OcSkeleton.vue +8 -8
- package/src/components/OcSlider.vue +27 -27
- package/src/components/OcSortableRow.vue +40 -40
- package/src/components/OcSpinner.vue +13 -13
- package/src/components/OcStackCard.vue +24 -24
- package/src/components/OcStackList.vue +31 -31
- package/src/components/OcStatCard.vue +39 -39
- package/src/components/OcStepper.vue +34 -34
- package/src/components/OcSwitch.vue +15 -15
- package/src/components/OcTable.vue +115 -115
- package/src/components/OcTableActions.vue +26 -26
- package/src/components/OcTableCompact.vue +46 -46
- package/src/components/OcTabs.vue +37 -37
- package/src/components/OcTagInput.vue +50 -50
- package/src/components/OcTerminalLog.vue +50 -50
- package/src/components/OcTextarea.vue +9 -9
- package/src/components/OcThemeSwitch.vue +31 -31
- package/src/components/OcTimeInput.vue +50 -50
- package/src/components/OcTimeline.vue +36 -36
- package/src/components/OcToast.vue +31 -31
- package/src/components/OcToastContainer.vue +21 -21
- package/src/components/OcToggle.vue +63 -63
- package/src/components/OcToggleField.vue +28 -28
- package/src/components/OcToggleGroup.vue +40 -40
- package/src/components/OcTooltip.vue +8 -8
- package/src/components/OcTopProgress.vue +54 -54
- package/src/components/OcTree.vue +70 -70
- package/src/components/OcUpgradeBanner.vue +42 -42
- package/src/components/OcUser.vue +25 -25
- package/src/components/OcVersionBanner.vue +34 -34
- package/src/composables/chartLine.ts +28 -28
- package/src/composables/keys.ts +22 -22
- package/src/composables/useAttrsSplit.ts +16 -16
- package/src/composables/useDialog.ts +21 -21
- package/src/composables/useToast.ts +66 -66
- package/src/composables/useUid.ts +12 -12
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
<!-- File: packages/vue/src/components/OcMessageList.vue | Map: props + demo conversation -> .oc-message-list -> dividers and OcMessage rows -->
|
|
2
|
-
<script setup lang="ts">
|
|
3
|
-
/**
|
|
4
|
-
* Column of messages and dividers (snippets/message.html). items: (MessageData | { divider, system? })[]; demo conversation
|
|
5
|
-
* when empty; slot default replaces them. log = role="log" (default true). Emits: action(name, message, index).
|
|
6
|
-
* Classes: .oc-message-list, .oc-message-divider(--system)
|
|
7
|
-
*/
|
|
8
|
-
import OcMessage, { type MessageData } from './OcMessage.vue'
|
|
9
|
-
|
|
10
|
-
export type MessageListItem = MessageData | { divider: string; system?: boolean }
|
|
11
|
-
withDefaults(defineProps<{ items?: MessageListItem[]; log?: boolean; label?: string }>(), {
|
|
12
|
-
log: true,
|
|
13
|
-
label: 'Conversation with Lisa Verbeke',
|
|
14
|
-
items: () => [
|
|
15
|
-
{ divider: 'Tuesday 14 October' },
|
|
16
|
-
{ sender: 'Lisa Verbeke', avatar: 'LV', time: '09:12', datetime: '2026-10-14T09:12', label: 'Lisa Verbeke, 09:12', text: 'Morning Tom! The worktop supplier sent two samples for Villa Dewitte.' },
|
|
17
|
-
{
|
|
18
|
-
sender: 'Lisa Verbeke', avatar: 'LV', time: '09:13', datetime: '2026-10-14T09:13', label: 'Lisa Verbeke, 09:13', continued: true, id: 'oc-message-demo-question',
|
|
19
|
-
html: '<p>Which finish should we order?</p><ul><li><strong>Calacatta</strong> matt, 20 mm</li><li><strong>Nero Marquina</strong> polished, 12 mm</li></ul><p>Details are on the <a href="#supplier-sheet">supplier sheet</a>.</p>',
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
direction: 'out', avatar: 'TP', time: '09:20', datetime: '2026-10-14T09:20', label: 'You, 09:20', status: 'read', statusIcon: true,
|
|
23
|
-
quote: { href: '#oc-message-demo-question', sender: 'Lisa Verbeke', text: 'Which finish should we order? Calacatta matt, 20 mm or Nero Marquina polished, 12 mm' },
|
|
24
|
-
text: 'Calacatta matt. The client wants the lighter look next to the oak fronts.',
|
|
25
|
-
actions: [{ name: 'reply', label: 'Reply to this message' }, { name: 'delete', label: 'Delete this message', destructive: true }],
|
|
26
|
-
},
|
|
27
|
-
{ divider: 'Lisa Verbeke marked this question as answered', system: true },
|
|
28
|
-
],
|
|
29
|
-
})
|
|
30
|
-
const emit = defineEmits<{ action: [name: string, message: MessageData, index: number] }>()
|
|
31
|
-
const isDivider = (item: MessageListItem): item is { divider: string; system?: boolean } => 'divider' in item
|
|
32
|
-
</script>
|
|
33
|
-
|
|
34
|
-
<template>
|
|
35
|
-
<div class="oc-message-list" :role="log ? 'log' : undefined" :aria-label="log ? label : undefined">
|
|
36
|
-
<slot>
|
|
37
|
-
<template v-for="(item, i) in items" :key="i">
|
|
38
|
-
<div v-if="isDivider(item)" :class="['oc-message-divider', item.system && 'oc-message-divider--system']" :role="item.system ? 'note' : undefined"><span>{{ item.divider }}</span></div>
|
|
39
|
-
<OcMessage v-else v-bind="item" @action="(name: string) => emit('action', name, item as MessageData, i)" />
|
|
40
|
-
</template>
|
|
41
|
-
</slot>
|
|
42
|
-
</div>
|
|
43
|
-
</template>
|
|
1
|
+
<!-- File: packages/vue/src/components/OcMessageList.vue | Map: props + demo conversation -> .oc-message-list -> dividers and OcMessage rows -->
|
|
2
|
+
<script setup lang="ts">
|
|
3
|
+
/**
|
|
4
|
+
* Column of messages and dividers (snippets/message.html). items: (MessageData | { divider, system? })[]; demo conversation
|
|
5
|
+
* when empty; slot default replaces them. log = role="log" (default true). Emits: action(name, message, index).
|
|
6
|
+
* Classes: .oc-message-list, .oc-message-divider(--system)
|
|
7
|
+
*/
|
|
8
|
+
import OcMessage, { type MessageData } from './OcMessage.vue'
|
|
9
|
+
|
|
10
|
+
export type MessageListItem = MessageData | { divider: string; system?: boolean }
|
|
11
|
+
withDefaults(defineProps<{ items?: MessageListItem[]; log?: boolean; label?: string }>(), {
|
|
12
|
+
log: true,
|
|
13
|
+
label: 'Conversation with Lisa Verbeke',
|
|
14
|
+
items: () => [
|
|
15
|
+
{ divider: 'Tuesday 14 October' },
|
|
16
|
+
{ sender: 'Lisa Verbeke', avatar: 'LV', time: '09:12', datetime: '2026-10-14T09:12', label: 'Lisa Verbeke, 09:12', text: 'Morning Tom! The worktop supplier sent two samples for Villa Dewitte.' },
|
|
17
|
+
{
|
|
18
|
+
sender: 'Lisa Verbeke', avatar: 'LV', time: '09:13', datetime: '2026-10-14T09:13', label: 'Lisa Verbeke, 09:13', continued: true, id: 'oc-message-demo-question',
|
|
19
|
+
html: '<p>Which finish should we order?</p><ul><li><strong>Calacatta</strong> matt, 20 mm</li><li><strong>Nero Marquina</strong> polished, 12 mm</li></ul><p>Details are on the <a href="#supplier-sheet">supplier sheet</a>.</p>',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
direction: 'out', avatar: 'TP', time: '09:20', datetime: '2026-10-14T09:20', label: 'You, 09:20', status: 'read', statusIcon: true,
|
|
23
|
+
quote: { href: '#oc-message-demo-question', sender: 'Lisa Verbeke', text: 'Which finish should we order? Calacatta matt, 20 mm or Nero Marquina polished, 12 mm' },
|
|
24
|
+
text: 'Calacatta matt. The client wants the lighter look next to the oak fronts.',
|
|
25
|
+
actions: [{ name: 'reply', label: 'Reply to this message' }, { name: 'delete', label: 'Delete this message', destructive: true }],
|
|
26
|
+
},
|
|
27
|
+
{ divider: 'Lisa Verbeke marked this question as answered', system: true },
|
|
28
|
+
],
|
|
29
|
+
})
|
|
30
|
+
const emit = defineEmits<{ action: [name: string, message: MessageData, index: number] }>()
|
|
31
|
+
const isDivider = (item: MessageListItem): item is { divider: string; system?: boolean } => 'divider' in item
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<div class="oc-message-list" :role="log ? 'log' : undefined" :aria-label="log ? label : undefined">
|
|
36
|
+
<slot>
|
|
37
|
+
<template v-for="(item, i) in items" :key="i">
|
|
38
|
+
<div v-if="isDivider(item)" :class="['oc-message-divider', item.system && 'oc-message-divider--system']" :role="item.system ? 'note' : undefined"><span>{{ item.divider }}</span></div>
|
|
39
|
+
<OcMessage v-else v-bind="item" @action="(name: string) => emit('action', name, item as MessageData, i)" />
|
|
40
|
+
</template>
|
|
41
|
+
</slot>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
@@ -1,155 +1,155 @@
|
|
|
1
|
-
<!-- File: packages/vue/src/components/OcMessageThread.vue | Map: props + demo -> state (messages, sending) -> header (back slot, heading, people, badge, menu)
|
|
2
|
-
-> scroll (OcMessageList) -> footer (composer slot / form / note) -> busy overlay -->
|
|
3
|
-
<script setup lang="ts">
|
|
4
|
-
/**
|
|
5
|
-
* Scrolling conversation (same markup as snippets/message-thread.html). Like the snippet script, a sent text is appended as an
|
|
6
|
-
* outgoing bubble after the @send handler (prop onSend, may return a Promise) resolves; aria-busy shows the overlay meanwhile.
|
|
7
|
-
* Ctrl/Cmd + Enter sends. `manual` = you append messages yourself. Emits: menu(name), tool(name).
|
|
8
|
-
* Slots: back (start of the header), composer (replaces the form). Classes: .oc-message-thread(--plain) __header __heading __title
|
|
9
|
-
* __meta __people __actions __icon-btn __scroll __footer __note __busy
|
|
10
|
-
*/
|
|
11
|
-
import { ref, watch } from 'vue'
|
|
12
|
-
import OcMessageList, { type MessageListItem } from './OcMessageList.vue'
|
|
13
|
-
import type { MessageData } from './OcMessage.vue'
|
|
14
|
-
import { useUid } from '../composables/useUid'
|
|
15
|
-
|
|
16
|
-
export interface ThreadParticipant { initials: string; name?: string }
|
|
17
|
-
export type ThreadMenuItem = { name: string; label: string; destructive?: boolean } | { separator: true }
|
|
18
|
-
|
|
19
|
-
const props = withDefaults(
|
|
20
|
-
defineProps<{
|
|
21
|
-
title?: string
|
|
22
|
-
project?: string
|
|
23
|
-
meta?: string
|
|
24
|
-
participants?: ThreadParticipant[]
|
|
25
|
-
status?: { label: string; variant?: string } | null
|
|
26
|
-
menu?: ThreadMenuItem[] | null
|
|
27
|
-
messages?: MessageListItem[]
|
|
28
|
-
scrollLabel?: string
|
|
29
|
-
live?: boolean
|
|
30
|
-
busy?: boolean
|
|
31
|
-
/** Overlay title; empty string leaves the overlay out. */
|
|
32
|
-
busyTitle?: string
|
|
33
|
-
busyText?: string
|
|
34
|
-
note?: string
|
|
35
|
-
placeholder?: string
|
|
36
|
-
composerLabel?: string
|
|
37
|
-
inline?: boolean
|
|
38
|
-
tools?: Array<'attach' | 'emoji'>
|
|
39
|
-
hint?: string
|
|
40
|
-
manual?: boolean
|
|
41
|
-
me?: string
|
|
42
|
-
plain?: boolean
|
|
43
|
-
id?: string
|
|
44
|
-
onSend?: (text: string) => void | Promise<void>
|
|
45
|
-
}>(),
|
|
46
|
-
{
|
|
47
|
-
title: 'Kitchen worktop: which finish?',
|
|
48
|
-
project: 'Villa Dewitte',
|
|
49
|
-
meta: 'Lisa Verbeke → Tom Peeters · 14 Oct 2026',
|
|
50
|
-
participants: () => [{ initials: 'LV', name: 'Lisa Verbeke' }, { initials: 'TP', name: 'Tom Peeters' }],
|
|
51
|
-
status: () => ({ label: 'Urgent', variant: 'destructive' }),
|
|
52
|
-
menu: () => [{ name: 'unread', label: 'Mark as unread' }, { name: 'archive', label: 'Move to archive' }, { separator: true }, { name: 'delete', label: 'Delete conversation', destructive: true }],
|
|
53
|
-
messages: () => [
|
|
54
|
-
{ divider: 'Tuesday 14 October' },
|
|
55
|
-
{ sender: 'Lisa Verbeke', avatar: 'LV', time: '09:12', datetime: '2026-10-14T09:12', text: 'The supplier dropped off two worktop samples at the workshop. Photo attached.',
|
|
56
|
-
attachments: [{ kind: 'image', href: '#samples.jpg', alt: 'Two worktop samples on the workbench', art: 6, caption: 'samples.jpg · 2.1 MB' }] },
|
|
57
|
-
{ sender: 'Lisa Verbeke', avatar: 'LV', time: '09:13', datetime: '2026-10-14T09:13', continued: true,
|
|
58
|
-
html: '<p>Which one should we order: <strong>Calacatta matt</strong> or <strong>Nero Marquina polished</strong>? The cutting slot is Friday.</p>' },
|
|
59
|
-
{ direction: 'out', avatar: 'TP', time: '09:20', datetime: '2026-10-14T09:20', status: 'read', text: 'Calacatta matt, 20 mm. I\'ll send the updated quote to the client today.',
|
|
60
|
-
attachments: [{ name: 'Quote kitchen Dewitte v2.pdf', size: '1.2 MB', href: '#quote.pdf' }], actions: [{ name: 'delete', label: 'Delete this message', destructive: true }] },
|
|
61
|
-
{ divider: 'Today' },
|
|
62
|
-
{ sender: 'Lisa Verbeke', avatar: 'LV', time: '10:02', datetime: '2026-10-24T10:02', text: 'Client signed. Can you confirm the edge profile before 16:00?' },
|
|
63
|
-
],
|
|
64
|
-
scrollLabel: 'Messages',
|
|
65
|
-
live: true,
|
|
66
|
-
busyTitle: 'Sending message…',
|
|
67
|
-
busyText: 'Please wait, do not close this conversation.',
|
|
68
|
-
placeholder: 'Type your reply…',
|
|
69
|
-
composerLabel: 'Reply',
|
|
70
|
-
tools: () => ['attach', 'emoji'],
|
|
71
|
-
hint: 'Ctrl + Enter to send',
|
|
72
|
-
},
|
|
73
|
-
)
|
|
74
|
-
const emit = defineEmits<{ menu: [name: string]; tool: [name: 'attach' | 'emoji'] }>()
|
|
75
|
-
const auto = useUid('oc-thread')
|
|
76
|
-
const base = props.id ?? auto
|
|
77
|
-
const list = ref<MessageListItem[]>([...props.messages])
|
|
78
|
-
const sending = ref(false)
|
|
79
|
-
watch(() => props.messages, (m) => (list.value = [...m]))
|
|
80
|
-
const isOut = (m?: MessageListItem): m is MessageData => !!m && !('divider' in m) && m.direction === 'out'
|
|
81
|
-
|
|
82
|
-
async function send(e: Event) {
|
|
83
|
-
const form = e.target as HTMLFormElement
|
|
84
|
-
const field = form.elements.namedItem('message') as HTMLTextAreaElement
|
|
85
|
-
const text = field.value.trim()
|
|
86
|
-
if (!text) return
|
|
87
|
-
sending.value = true
|
|
88
|
-
try { await props.onSend?.(text) } catch { sending.value = false; return }
|
|
89
|
-
if (!props.manual) {
|
|
90
|
-
const l = list.value
|
|
91
|
-
const mine = props.me ?? [...l].reverse().find(isOut)?.avatar ?? 'Me'
|
|
92
|
-
list.value = [...l, { direction: 'out', avatar: mine, continued: isOut(l[l.length - 1]), text, time: new Date().toTimeString().slice(0, 5), status: 'sent' }]
|
|
93
|
-
}
|
|
94
|
-
field.value = ''
|
|
95
|
-
sending.value = false
|
|
96
|
-
field.focus()
|
|
97
|
-
}
|
|
98
|
-
function ctrlEnter(e: KeyboardEvent) {
|
|
99
|
-
if (e.key !== 'Enter' || !(e.ctrlKey || e.metaKey)) return
|
|
100
|
-
e.preventDefault()
|
|
101
|
-
;(e.currentTarget as HTMLFormElement).requestSubmit()
|
|
102
|
-
}
|
|
103
|
-
</script>
|
|
104
|
-
|
|
105
|
-
<template>
|
|
106
|
-
<section :class="['oc-message-thread', plain && 'oc-message-thread--plain']" :aria-labelledby="`${base}-title`" :aria-busy="busy || sending ? 'true' : undefined">
|
|
107
|
-
<header class="oc-message-thread__header">
|
|
108
|
-
<slot name="back" />
|
|
109
|
-
<div class="oc-message-thread__heading">
|
|
110
|
-
<h3 :id="`${base}-title`" class="oc-message-thread__title">{{ title }}</h3>
|
|
111
|
-
<p v-if="project || meta" class="oc-message-thread__meta"><strong v-if="project">{{ project }}</strong><template v-if="project && meta"> · </template>{{ meta }}</p>
|
|
112
|
-
</div>
|
|
113
|
-
<div v-if="participants.length" class="oc-avatar-group oc-message-thread__people" :aria-label="`${participants.length} participants`">
|
|
114
|
-
<span v-for="(p, i) in participants" :key="i" class="oc-avatar" :title="p.name">{{ p.initials }}</span>
|
|
115
|
-
</div>
|
|
116
|
-
<div v-if="status || menu" class="oc-message-thread__actions">
|
|
117
|
-
<span v-if="status" :class="['oc-badge', status.variant && `oc-badge--${status.variant}`]">{{ status.label }}</span>
|
|
118
|
-
<template v-if="menu">
|
|
119
|
-
<button type="button" class="oc-message-thread__icon-btn" :popovertarget="`${base}-menu`" aria-label="Conversation actions"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="12" cy="19" r="1.8" /></svg></button>
|
|
120
|
-
<div :id="`${base}-menu`" class="oc-dropdown-menu" popover role="menu">
|
|
121
|
-
<template v-for="(m, i) in menu" :key="i">
|
|
122
|
-
<div v-if="'separator' in m" class="oc-dropdown-separator" role="separator"></div>
|
|
123
|
-
<button v-else type="button" :class="['oc-dropdown-item', m.destructive && 'oc-dropdown-item--destructive']" role="menuitem" @click="emit('menu', m.name)">{{ m.label }}</button>
|
|
124
|
-
</template>
|
|
125
|
-
</div>
|
|
126
|
-
</template>
|
|
127
|
-
</div>
|
|
128
|
-
</header>
|
|
129
|
-
<div class="oc-message-thread__scroll" tabindex="0" role="log" :aria-label="scrollLabel" :aria-live="live ? 'polite' : undefined">
|
|
130
|
-
<OcMessageList :items="list" :log="false" />
|
|
131
|
-
</div>
|
|
132
|
-
<div class="oc-message-thread__footer">
|
|
133
|
-
<p v-if="note" class="oc-message-thread__note">{{ note }}</p>
|
|
134
|
-
<slot v-else name="composer">
|
|
135
|
-
<form :class="['oc-chat-composer', inline && 'oc-chat-composer--inline']" action="#" :aria-label="composerLabel" @submit.prevent="send" @keydown="ctrlEnter">
|
|
136
|
-
<label class="oc-visually-hidden" :for="`${base}-input`">Message</label>
|
|
137
|
-
<textarea :id="`${base}-input`" class="oc-chat-composer__input" name="message" rows="1" :placeholder="placeholder" required></textarea>
|
|
138
|
-
<div class="oc-chat-composer__bar">
|
|
139
|
-
<div v-if="tools.length" class="oc-chat-composer__tools">
|
|
140
|
-
<button v-if="tools.includes('attach')" type="button" class="oc-chat-composer__tool" aria-label="Attach a file" @click="emit('tool', 'attach')">📎</button>
|
|
141
|
-
<button v-if="tools.includes('emoji')" type="button" class="oc-chat-composer__tool" aria-label="Insert emoji" @click="emit('tool', 'emoji')">🙂</button>
|
|
142
|
-
</div>
|
|
143
|
-
<span v-if="hint" class="oc-chat-composer__hint">{{ hint }}</span>
|
|
144
|
-
<button type="submit" class="oc-chat-composer__send" aria-label="Send">➤</button>
|
|
145
|
-
</div>
|
|
146
|
-
</form>
|
|
147
|
-
</slot>
|
|
148
|
-
</div>
|
|
149
|
-
<div v-if="busyTitle" class="oc-message-thread__busy" role="status">
|
|
150
|
-
<span class="oc-spinner oc-spinner--lg" aria-hidden="true"></span>
|
|
151
|
-
<strong>{{ busyTitle }}</strong>
|
|
152
|
-
<span v-if="busyText">{{ busyText }}</span>
|
|
153
|
-
</div>
|
|
154
|
-
</section>
|
|
155
|
-
</template>
|
|
1
|
+
<!-- File: packages/vue/src/components/OcMessageThread.vue | Map: props + demo -> state (messages, sending) -> header (back slot, heading, people, badge, menu)
|
|
2
|
+
-> scroll (OcMessageList) -> footer (composer slot / form / note) -> busy overlay -->
|
|
3
|
+
<script setup lang="ts">
|
|
4
|
+
/**
|
|
5
|
+
* Scrolling conversation (same markup as snippets/message-thread.html). Like the snippet script, a sent text is appended as an
|
|
6
|
+
* outgoing bubble after the @send handler (prop onSend, may return a Promise) resolves; aria-busy shows the overlay meanwhile.
|
|
7
|
+
* Ctrl/Cmd + Enter sends. `manual` = you append messages yourself. Emits: menu(name), tool(name).
|
|
8
|
+
* Slots: back (start of the header), composer (replaces the form). Classes: .oc-message-thread(--plain) __header __heading __title
|
|
9
|
+
* __meta __people __actions __icon-btn __scroll __footer __note __busy
|
|
10
|
+
*/
|
|
11
|
+
import { ref, watch } from 'vue'
|
|
12
|
+
import OcMessageList, { type MessageListItem } from './OcMessageList.vue'
|
|
13
|
+
import type { MessageData } from './OcMessage.vue'
|
|
14
|
+
import { useUid } from '../composables/useUid'
|
|
15
|
+
|
|
16
|
+
export interface ThreadParticipant { initials: string; name?: string }
|
|
17
|
+
export type ThreadMenuItem = { name: string; label: string; destructive?: boolean } | { separator: true }
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(
|
|
20
|
+
defineProps<{
|
|
21
|
+
title?: string
|
|
22
|
+
project?: string
|
|
23
|
+
meta?: string
|
|
24
|
+
participants?: ThreadParticipant[]
|
|
25
|
+
status?: { label: string; variant?: string } | null
|
|
26
|
+
menu?: ThreadMenuItem[] | null
|
|
27
|
+
messages?: MessageListItem[]
|
|
28
|
+
scrollLabel?: string
|
|
29
|
+
live?: boolean
|
|
30
|
+
busy?: boolean
|
|
31
|
+
/** Overlay title; empty string leaves the overlay out. */
|
|
32
|
+
busyTitle?: string
|
|
33
|
+
busyText?: string
|
|
34
|
+
note?: string
|
|
35
|
+
placeholder?: string
|
|
36
|
+
composerLabel?: string
|
|
37
|
+
inline?: boolean
|
|
38
|
+
tools?: Array<'attach' | 'emoji'>
|
|
39
|
+
hint?: string
|
|
40
|
+
manual?: boolean
|
|
41
|
+
me?: string
|
|
42
|
+
plain?: boolean
|
|
43
|
+
id?: string
|
|
44
|
+
onSend?: (text: string) => void | Promise<void>
|
|
45
|
+
}>(),
|
|
46
|
+
{
|
|
47
|
+
title: 'Kitchen worktop: which finish?',
|
|
48
|
+
project: 'Villa Dewitte',
|
|
49
|
+
meta: 'Lisa Verbeke → Tom Peeters · 14 Oct 2026',
|
|
50
|
+
participants: () => [{ initials: 'LV', name: 'Lisa Verbeke' }, { initials: 'TP', name: 'Tom Peeters' }],
|
|
51
|
+
status: () => ({ label: 'Urgent', variant: 'destructive' }),
|
|
52
|
+
menu: () => [{ name: 'unread', label: 'Mark as unread' }, { name: 'archive', label: 'Move to archive' }, { separator: true }, { name: 'delete', label: 'Delete conversation', destructive: true }],
|
|
53
|
+
messages: () => [
|
|
54
|
+
{ divider: 'Tuesday 14 October' },
|
|
55
|
+
{ sender: 'Lisa Verbeke', avatar: 'LV', time: '09:12', datetime: '2026-10-14T09:12', text: 'The supplier dropped off two worktop samples at the workshop. Photo attached.',
|
|
56
|
+
attachments: [{ kind: 'image', href: '#samples.jpg', alt: 'Two worktop samples on the workbench', art: 6, caption: 'samples.jpg · 2.1 MB' }] },
|
|
57
|
+
{ sender: 'Lisa Verbeke', avatar: 'LV', time: '09:13', datetime: '2026-10-14T09:13', continued: true,
|
|
58
|
+
html: '<p>Which one should we order: <strong>Calacatta matt</strong> or <strong>Nero Marquina polished</strong>? The cutting slot is Friday.</p>' },
|
|
59
|
+
{ direction: 'out', avatar: 'TP', time: '09:20', datetime: '2026-10-14T09:20', status: 'read', text: 'Calacatta matt, 20 mm. I\'ll send the updated quote to the client today.',
|
|
60
|
+
attachments: [{ name: 'Quote kitchen Dewitte v2.pdf', size: '1.2 MB', href: '#quote.pdf' }], actions: [{ name: 'delete', label: 'Delete this message', destructive: true }] },
|
|
61
|
+
{ divider: 'Today' },
|
|
62
|
+
{ sender: 'Lisa Verbeke', avatar: 'LV', time: '10:02', datetime: '2026-10-24T10:02', text: 'Client signed. Can you confirm the edge profile before 16:00?' },
|
|
63
|
+
],
|
|
64
|
+
scrollLabel: 'Messages',
|
|
65
|
+
live: true,
|
|
66
|
+
busyTitle: 'Sending message…',
|
|
67
|
+
busyText: 'Please wait, do not close this conversation.',
|
|
68
|
+
placeholder: 'Type your reply…',
|
|
69
|
+
composerLabel: 'Reply',
|
|
70
|
+
tools: () => ['attach', 'emoji'],
|
|
71
|
+
hint: 'Ctrl + Enter to send',
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
const emit = defineEmits<{ menu: [name: string]; tool: [name: 'attach' | 'emoji'] }>()
|
|
75
|
+
const auto = useUid('oc-thread')
|
|
76
|
+
const base = props.id ?? auto
|
|
77
|
+
const list = ref<MessageListItem[]>([...props.messages])
|
|
78
|
+
const sending = ref(false)
|
|
79
|
+
watch(() => props.messages, (m) => (list.value = [...m]))
|
|
80
|
+
const isOut = (m?: MessageListItem): m is MessageData => !!m && !('divider' in m) && m.direction === 'out'
|
|
81
|
+
|
|
82
|
+
async function send(e: Event) {
|
|
83
|
+
const form = e.target as HTMLFormElement
|
|
84
|
+
const field = form.elements.namedItem('message') as HTMLTextAreaElement
|
|
85
|
+
const text = field.value.trim()
|
|
86
|
+
if (!text) return
|
|
87
|
+
sending.value = true
|
|
88
|
+
try { await props.onSend?.(text) } catch { sending.value = false; return }
|
|
89
|
+
if (!props.manual) {
|
|
90
|
+
const l = list.value
|
|
91
|
+
const mine = props.me ?? [...l].reverse().find(isOut)?.avatar ?? 'Me'
|
|
92
|
+
list.value = [...l, { direction: 'out', avatar: mine, continued: isOut(l[l.length - 1]), text, time: new Date().toTimeString().slice(0, 5), status: 'sent' }]
|
|
93
|
+
}
|
|
94
|
+
field.value = ''
|
|
95
|
+
sending.value = false
|
|
96
|
+
field.focus()
|
|
97
|
+
}
|
|
98
|
+
function ctrlEnter(e: KeyboardEvent) {
|
|
99
|
+
if (e.key !== 'Enter' || !(e.ctrlKey || e.metaKey)) return
|
|
100
|
+
e.preventDefault()
|
|
101
|
+
;(e.currentTarget as HTMLFormElement).requestSubmit()
|
|
102
|
+
}
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<template>
|
|
106
|
+
<section :class="['oc-message-thread', plain && 'oc-message-thread--plain']" :aria-labelledby="`${base}-title`" :aria-busy="busy || sending ? 'true' : undefined">
|
|
107
|
+
<header class="oc-message-thread__header">
|
|
108
|
+
<slot name="back" />
|
|
109
|
+
<div class="oc-message-thread__heading">
|
|
110
|
+
<h3 :id="`${base}-title`" class="oc-message-thread__title">{{ title }}</h3>
|
|
111
|
+
<p v-if="project || meta" class="oc-message-thread__meta"><strong v-if="project">{{ project }}</strong><template v-if="project && meta"> · </template>{{ meta }}</p>
|
|
112
|
+
</div>
|
|
113
|
+
<div v-if="participants.length" class="oc-avatar-group oc-message-thread__people" :aria-label="`${participants.length} participants`">
|
|
114
|
+
<span v-for="(p, i) in participants" :key="i" class="oc-avatar" :title="p.name">{{ p.initials }}</span>
|
|
115
|
+
</div>
|
|
116
|
+
<div v-if="status || menu" class="oc-message-thread__actions">
|
|
117
|
+
<span v-if="status" :class="['oc-badge', status.variant && `oc-badge--${status.variant}`]">{{ status.label }}</span>
|
|
118
|
+
<template v-if="menu">
|
|
119
|
+
<button type="button" class="oc-message-thread__icon-btn" :popovertarget="`${base}-menu`" aria-label="Conversation actions"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="12" cy="19" r="1.8" /></svg></button>
|
|
120
|
+
<div :id="`${base}-menu`" class="oc-dropdown-menu" popover role="menu">
|
|
121
|
+
<template v-for="(m, i) in menu" :key="i">
|
|
122
|
+
<div v-if="'separator' in m" class="oc-dropdown-separator" role="separator"></div>
|
|
123
|
+
<button v-else type="button" :class="['oc-dropdown-item', m.destructive && 'oc-dropdown-item--destructive']" role="menuitem" @click="emit('menu', m.name)">{{ m.label }}</button>
|
|
124
|
+
</template>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
127
|
+
</div>
|
|
128
|
+
</header>
|
|
129
|
+
<div class="oc-message-thread__scroll" tabindex="0" role="log" :aria-label="scrollLabel" :aria-live="live ? 'polite' : undefined">
|
|
130
|
+
<OcMessageList :items="list" :log="false" />
|
|
131
|
+
</div>
|
|
132
|
+
<div class="oc-message-thread__footer">
|
|
133
|
+
<p v-if="note" class="oc-message-thread__note">{{ note }}</p>
|
|
134
|
+
<slot v-else name="composer">
|
|
135
|
+
<form :class="['oc-chat-composer', inline && 'oc-chat-composer--inline']" action="#" :aria-label="composerLabel" @submit.prevent="send" @keydown="ctrlEnter">
|
|
136
|
+
<label class="oc-visually-hidden" :for="`${base}-input`">Message</label>
|
|
137
|
+
<textarea :id="`${base}-input`" class="oc-chat-composer__input" name="message" rows="1" :placeholder="placeholder" required></textarea>
|
|
138
|
+
<div class="oc-chat-composer__bar">
|
|
139
|
+
<div v-if="tools.length" class="oc-chat-composer__tools">
|
|
140
|
+
<button v-if="tools.includes('attach')" type="button" class="oc-chat-composer__tool" aria-label="Attach a file" @click="emit('tool', 'attach')">📎</button>
|
|
141
|
+
<button v-if="tools.includes('emoji')" type="button" class="oc-chat-composer__tool" aria-label="Insert emoji" @click="emit('tool', 'emoji')">🙂</button>
|
|
142
|
+
</div>
|
|
143
|
+
<span v-if="hint" class="oc-chat-composer__hint">{{ hint }}</span>
|
|
144
|
+
<button type="submit" class="oc-chat-composer__send" aria-label="Send">➤</button>
|
|
145
|
+
</div>
|
|
146
|
+
</form>
|
|
147
|
+
</slot>
|
|
148
|
+
</div>
|
|
149
|
+
<div v-if="busyTitle" class="oc-message-thread__busy" role="status">
|
|
150
|
+
<span class="oc-spinner oc-spinner--lg" aria-hidden="true"></span>
|
|
151
|
+
<strong>{{ busyTitle }}</strong>
|
|
152
|
+
<span v-if="busyText">{{ busyText }}</span>
|
|
153
|
+
</div>
|
|
154
|
+
</section>
|
|
155
|
+
</template>
|
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* Modal on a native <dialog> opened with showModal(). v-model:open controls it; Esc / backdrop /
|
|
4
|
-
* close() update the model. Slots: header (replaces title + description), default (body),
|
|
5
|
-
* footer (scoped: { close }). Classes: .oc-modal, .oc-modal-content(--sm/--lg/--xl/--full), -header, -title,
|
|
6
|
-
* -description, -body, -footer.
|
|
7
|
-
*/
|
|
8
|
-
import { ref } from 'vue'
|
|
9
|
-
import { useDialog } from '../composables/useDialog'
|
|
10
|
-
import { useUid } from '../composables/useUid'
|
|
11
|
-
|
|
12
|
-
withDefaults(
|
|
13
|
-
defineProps<{
|
|
14
|
-
title?: string
|
|
15
|
-
description?: string
|
|
16
|
-
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
|
17
|
-
closedby?: 'any' | 'closerequest' | 'none'
|
|
18
|
-
}>(),
|
|
19
|
-
{ size: 'md', closedby: 'any' },
|
|
20
|
-
)
|
|
21
|
-
const open = defineModel<boolean>('open', { default: false })
|
|
22
|
-
const el = ref<HTMLDialogElement | null>(null)
|
|
23
|
-
const { onClose } = useDialog(open, el)
|
|
24
|
-
const titleId = useUid('oc-modal-title')
|
|
25
|
-
const close = () => (open.value = false)
|
|
26
|
-
defineExpose({ close })
|
|
27
|
-
</script>
|
|
28
|
-
|
|
29
|
-
<template>
|
|
30
|
-
<dialog ref="el" class="oc-modal" :closedby="closedby" :aria-labelledby="title ? titleId : undefined" @close="onClose">
|
|
31
|
-
<div :class="['oc-modal-content', size !== 'md' && `oc-modal-content--${size}`]">
|
|
32
|
-
<div v-if="title || description || $slots.header" class="oc-modal-header">
|
|
33
|
-
<slot name="header">
|
|
34
|
-
<h2 v-if="title" :id="titleId" class="oc-modal-title">{{ title }}</h2>
|
|
35
|
-
<p v-if="description" class="oc-modal-description">{{ description }}</p>
|
|
36
|
-
</slot>
|
|
37
|
-
</div>
|
|
38
|
-
<div v-if="$slots.default" class="oc-modal-body"><slot /></div>
|
|
39
|
-
<div v-if="$slots.footer" class="oc-modal-footer"><slot name="footer" :close="close" /></div>
|
|
40
|
-
</div>
|
|
41
|
-
</dialog>
|
|
42
|
-
</template>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Modal on a native <dialog> opened with showModal(). v-model:open controls it; Esc / backdrop /
|
|
4
|
+
* close() update the model. Slots: header (replaces title + description), default (body),
|
|
5
|
+
* footer (scoped: { close }). Classes: .oc-modal, .oc-modal-content(--sm/--lg/--xl/--full), -header, -title,
|
|
6
|
+
* -description, -body, -footer.
|
|
7
|
+
*/
|
|
8
|
+
import { ref } from 'vue'
|
|
9
|
+
import { useDialog } from '../composables/useDialog'
|
|
10
|
+
import { useUid } from '../composables/useUid'
|
|
11
|
+
|
|
12
|
+
withDefaults(
|
|
13
|
+
defineProps<{
|
|
14
|
+
title?: string
|
|
15
|
+
description?: string
|
|
16
|
+
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
|
17
|
+
closedby?: 'any' | 'closerequest' | 'none'
|
|
18
|
+
}>(),
|
|
19
|
+
{ size: 'md', closedby: 'any' },
|
|
20
|
+
)
|
|
21
|
+
const open = defineModel<boolean>('open', { default: false })
|
|
22
|
+
const el = ref<HTMLDialogElement | null>(null)
|
|
23
|
+
const { onClose } = useDialog(open, el)
|
|
24
|
+
const titleId = useUid('oc-modal-title')
|
|
25
|
+
const close = () => (open.value = false)
|
|
26
|
+
defineExpose({ close })
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<dialog ref="el" class="oc-modal" :closedby="closedby" :aria-labelledby="title ? titleId : undefined" @close="onClose">
|
|
31
|
+
<div :class="['oc-modal-content', size !== 'md' && `oc-modal-content--${size}`]">
|
|
32
|
+
<div v-if="title || description || $slots.header" class="oc-modal-header">
|
|
33
|
+
<slot name="header">
|
|
34
|
+
<h2 v-if="title" :id="titleId" class="oc-modal-title">{{ title }}</h2>
|
|
35
|
+
<p v-if="description" class="oc-modal-description">{{ description }}</p>
|
|
36
|
+
</slot>
|
|
37
|
+
</div>
|
|
38
|
+
<div v-if="$slots.default" class="oc-modal-body"><slot /></div>
|
|
39
|
+
<div v-if="$slots.footer" class="oc-modal-footer"><slot name="footer" :close="close" /></div>
|
|
40
|
+
</div>
|
|
41
|
+
</dialog>
|
|
42
|
+
</template>
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* Horizontal navigation links with optional dropdown children, wrapped in a <nav>.
|
|
4
|
-
* Pass `items` (see OcNavmenuList) or put your own <li> elements in the default slot.
|
|
5
|
-
*/
|
|
6
|
-
import OcNavmenuList, { type NavmenuItem } from './OcNavmenuList.vue'
|
|
7
|
-
|
|
8
|
-
withDefaults(defineProps<{ items?: NavmenuItem[]; label?: string }>(), {
|
|
9
|
-
items: () => [],
|
|
10
|
-
label: 'Navigation',
|
|
11
|
-
})
|
|
12
|
-
</script>
|
|
13
|
-
|
|
14
|
-
<template>
|
|
15
|
-
<nav :aria-label="label">
|
|
16
|
-
<OcNavmenuList :items="items"><slot /></OcNavmenuList>
|
|
17
|
-
</nav>
|
|
18
|
-
</template>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Horizontal navigation links with optional dropdown children, wrapped in a <nav>.
|
|
4
|
+
* Pass `items` (see OcNavmenuList) or put your own <li> elements in the default slot.
|
|
5
|
+
*/
|
|
6
|
+
import OcNavmenuList, { type NavmenuItem } from './OcNavmenuList.vue'
|
|
7
|
+
|
|
8
|
+
withDefaults(defineProps<{ items?: NavmenuItem[]; label?: string }>(), {
|
|
9
|
+
items: () => [],
|
|
10
|
+
label: 'Navigation',
|
|
11
|
+
})
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<nav :aria-label="label">
|
|
16
|
+
<OcNavmenuList :items="items"><slot /></OcNavmenuList>
|
|
17
|
+
</nav>
|
|
18
|
+
</template>
|
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* The <ul class="oc-navmenu"> of the navigation menu, without the surrounding <nav>
|
|
4
|
-
* (used by OcNavmenu and by blocks that provide their own <nav>).
|
|
5
|
-
* Pass `items`, or put your own <li> elements in the default slot.
|
|
6
|
-
* Classes: .oc-navmenu, .oc-navmenu-link(--caret) with aria-current="page"
|
|
7
|
-
*/
|
|
8
|
-
import { useUid } from '../composables/useUid'
|
|
9
|
-
|
|
10
|
-
export interface NavmenuItem {
|
|
11
|
-
label: string
|
|
12
|
-
href?: string
|
|
13
|
-
current?: boolean
|
|
14
|
-
/** Dropdown entries; turns the item into a popover trigger. */
|
|
15
|
-
children?: { label: string; href: string }[]
|
|
16
|
-
/** Heading inside the dropdown. */
|
|
17
|
-
heading?: string
|
|
18
|
-
}
|
|
19
|
-
withDefaults(defineProps<{ items?: NavmenuItem[] }>(), { items: () => [] })
|
|
20
|
-
const base = useUid('oc-navmenu')
|
|
21
|
-
</script>
|
|
22
|
-
|
|
23
|
-
<template>
|
|
24
|
-
<ul class="oc-navmenu">
|
|
25
|
-
<slot>
|
|
26
|
-
<li v-for="(item, i) in items" :key="i">
|
|
27
|
-
<template v-if="item.children?.length">
|
|
28
|
-
<button class="oc-navmenu-link oc-navmenu-link--caret" type="button" :popovertarget="`${base}-${i}`">{{ item.label }}</button>
|
|
29
|
-
<div :id="`${base}-${i}`" class="oc-dropdown-menu" popover>
|
|
30
|
-
<div v-if="item.heading" class="oc-dropdown-label">{{ item.heading }}</div>
|
|
31
|
-
<a v-for="child in item.children" :key="child.href + child.label" class="oc-dropdown-item" :href="child.href">{{ child.label }}</a>
|
|
32
|
-
</div>
|
|
33
|
-
</template>
|
|
34
|
-
<a v-else class="oc-navmenu-link" :href="item.href ?? '#'" :aria-current="item.current ? 'page' : undefined">{{ item.label }}</a>
|
|
35
|
-
</li>
|
|
36
|
-
</slot>
|
|
37
|
-
</ul>
|
|
38
|
-
</template>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The <ul class="oc-navmenu"> of the navigation menu, without the surrounding <nav>
|
|
4
|
+
* (used by OcNavmenu and by blocks that provide their own <nav>).
|
|
5
|
+
* Pass `items`, or put your own <li> elements in the default slot.
|
|
6
|
+
* Classes: .oc-navmenu, .oc-navmenu-link(--caret) with aria-current="page"
|
|
7
|
+
*/
|
|
8
|
+
import { useUid } from '../composables/useUid'
|
|
9
|
+
|
|
10
|
+
export interface NavmenuItem {
|
|
11
|
+
label: string
|
|
12
|
+
href?: string
|
|
13
|
+
current?: boolean
|
|
14
|
+
/** Dropdown entries; turns the item into a popover trigger. */
|
|
15
|
+
children?: { label: string; href: string }[]
|
|
16
|
+
/** Heading inside the dropdown. */
|
|
17
|
+
heading?: string
|
|
18
|
+
}
|
|
19
|
+
withDefaults(defineProps<{ items?: NavmenuItem[] }>(), { items: () => [] })
|
|
20
|
+
const base = useUid('oc-navmenu')
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<ul class="oc-navmenu">
|
|
25
|
+
<slot>
|
|
26
|
+
<li v-for="(item, i) in items" :key="i">
|
|
27
|
+
<template v-if="item.children?.length">
|
|
28
|
+
<button class="oc-navmenu-link oc-navmenu-link--caret" type="button" :popovertarget="`${base}-${i}`">{{ item.label }}</button>
|
|
29
|
+
<div :id="`${base}-${i}`" class="oc-dropdown-menu" popover>
|
|
30
|
+
<div v-if="item.heading" class="oc-dropdown-label">{{ item.heading }}</div>
|
|
31
|
+
<a v-for="child in item.children" :key="child.href + child.label" class="oc-dropdown-item" :href="child.href">{{ child.label }}</a>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
<a v-else class="oc-navmenu-link" :href="item.href ?? '#'" :aria-current="item.current ? 'page' : undefined">{{ item.label }}</a>
|
|
35
|
+
</li>
|
|
36
|
+
</slot>
|
|
37
|
+
</ul>
|
|
38
|
+
</template>
|