vectis-ui 0.9.0 → 0.10.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.
@@ -1 +1 @@
1
- {"version":3,"file":"VInput.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../../src/components/VInput/VInput.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @core\n/**\n * A complete text field: label above, hint below, icons inside (clickable ones included), a\n * counter, a clear cross and a loading state, all arranged around a real `<input>`.\n *\n * Wrapper-root, so the attrs split: `class`/`style` stay on the root, where a consumer\n * expects to style the field, and everything else is forwarded to the input, where `name`,\n * `required` and the `aria-*` actually do something.\n *\n * Validation stays the browser's. The field turns red through `:user-invalid`, which only\n * reacts once the reader has left it, and even the soft limit is reported by\n * `setCustomValidity` rather than an event of our own — so a consumer's `<form>` sees\n * exactly what a bare input would give it.\n *\n * The behavioural JS is the v-model and the clear cross, which has to move focus back to the\n * field: it disappears the moment it is clicked, and focus would otherwise fall to the page.\n */\nimport { computed, inject, ref } from 'vue'\n\nimport VIcon from '../VIcon/VIcon.vue'\nimport { iconName, iconProps } from '../VIcon/iconProps'\nimport { close as closeIcon } from '../VIcon/icons/close'\nimport type { IconSource } from '../VIcon/types'\nimport VSpinner from '../VSpinner/VSpinner.vue'\nimport VTypography from '../VTypography/VTypography.vue'\n\nimport { useFieldIds } from '../../composables/useFieldIds'\nimport { useIconClickHandlers } from '../../composables/useIconClickHandlers'\nimport { useClearable } from '../../composables/useClearable'\nimport { useControlShape } from '../../composables/useControlShape'\nimport { useRootAttrs } from '../../composables/useRootAttrs'\nimport { useTextLimit } from '../../composables/useTextLimit'\nimport { useMessages } from '../../i18n/state'\n\nimport { inputGroupKey } from './context'\n\n/** The height of the field: 32, 40 or 48 pixels. */\nexport type InputSize = 'sm' | 'md' | 'lg'\n\n/** The native type of the input, which also decides the keyboard a phone offers. */\nexport type InputType = 'text' | 'email' | 'number' | 'password' | 'search' | 'tel' | 'url'\n\ninterface InputProps {\n /** The height of the field: 32, 40 or 48 pixels. */\n size?: InputSize\n /** Takes 4px off the height, leaving the padding, the text and the icons as they are. */\n compact?: boolean\n /**\n * The native type of the input, which is also what tells a phone which keyboard to\n * offer: a numeric pad for `number`, an @ key for `email`.\n */\n type?: InputType\n /**\n * Marks the field as invalid whatever the browser thinks. This is the route for a\n * rule only the server can check; anything the browser can validate on its own\n * already colours the field without this prop.\n */\n invalid?: boolean\n /** Makes the field unusable, greyed out through the colour tokens. */\n disabled?: boolean\n /**\n * Shows the value without allowing it to be changed. The field can still be\n * focused and copied from, and it hides the clear button, unless `clearVisible`\n * answers that question explicitly.\n */\n readonly?: boolean\n /**\n * Refuses the keyboard without drawing the field as read-only: the native attribute is\n * set, but the field keeps its ordinary look and its clear cross. For a field whose value\n * comes from somewhere else, a picker or a file dialog, and which is no less editable for\n * it.\n */\n noTyping?: boolean\n /** The label above the field, tied to it so that clicking it focuses the field. */\n label?: string\n /**\n * A line of help under the field. It is tied to the input for assistive\n * technology, so it is read out along with the label.\n */\n hint?: string\n /**\n * An icon inside the field, at the start. It is decorative by default and becomes\n * a real button as soon as a `@click:icon-start` listener is attached, in which\n * case it needs `iconStartLabel`. The `#start` slot is rendered after it, so a\n * field composed on top of this one can show both.\n */\n iconStart?: IconSource\n /**\n * The same at the end of the field. The `#end` slot replaces it, and the loading\n * spinner takes its place while it turns.\n */\n iconEnd?: IconSource\n /** What the start icon does, in words, once it is clickable. */\n iconStartLabel?: string\n /** What the end icon does, in words, once it is clickable. */\n iconEndLabel?: string\n /** Shows a spinner at the end of the field, in place of the end icon or slot. */\n loading?: boolean\n /**\n * What screen readers announce while the spinner turns. It falls back to the\n * design system dictionary.\n */\n loadingText?: string\n /**\n * Offers a cross that empties the field. It appears when there is something to\n * clear and the field can be edited.\n */\n clearable?: boolean\n /**\n * Decides whether the cross is shown, instead of letting the field work it out\n * from its own content (a read-only field included).\n *\n * It exists for the components built on top of this one, where what there is to\n * clear is not the text: VCombobox holds its selection as chips beside the field,\n * and a read-only date or time picker changes its value through a panel rather\n * than by typing.\n */\n clearVisible?: boolean\n /** What the clear button does, in words. It falls back to the design system dictionary. */\n clearLabel?: string\n /**\n * The maximum number of characters. By default this is the browser's own limit,\n * which simply refuses anything beyond it.\n */\n maxlength?: number\n /**\n * Turns that limit into a soft one: the reader may type past it, and the field\n * goes into error instead of silently refusing the keystrokes. It is reported\n * through the native validity, so a form cannot be submitted over the limit.\n */\n softLimit?: boolean\n /**\n * Shows how much has been typed, at the end of the field: \"12/80\" against a limit,\n * or just \"12\" without one.\n */\n counter?: boolean\n}\n\ndefineOptions({ inheritAttrs: false })\n\nconst props = withDefaults(defineProps<InputProps>(), {\n size: 'md',\n compact: false,\n type: 'text',\n invalid: false,\n disabled: false,\n readonly: false,\n label: undefined,\n hint: undefined,\n iconStart: undefined,\n iconEnd: undefined,\n iconStartLabel: undefined,\n iconEndLabel: undefined,\n loading: false,\n loadingText: undefined,\n clearable: false,\n clearVisible: undefined,\n clearLabel: undefined,\n maxlength: undefined,\n softLimit: false,\n noTyping: false,\n counter: false,\n})\n\nconst emit = defineEmits<{\n /** The start icon was clicked. Attaching this listener is what makes it a button. */\n 'click:icon-start': [event: MouseEvent]\n /** The end icon was clicked. Attaching this listener is what makes it a button. */\n 'click:icon-end': [event: MouseEvent]\n /** The clear button was pressed. The field has already been emptied. */\n clear: []\n}>()\n\ndefineSlots<{\n /**\n * Content at the start of the field, rendered after `iconStart` rather than in its\n * place, which is what lets a field composed on top of this one put the chips\n * standing for its values here beside an icon it still wants drawn.\n */\n start?(): unknown\n /**\n * Controls of your own inside the field, placed before the field's own: the clear\n * cross and the end icon. It is the slot for something that acts on the VALUE, such\n * as the AM/PM button of a 12-hour time field, which belongs beside the text rather\n * than past the controls that clear and open.\n */\n 'value-end'?(): unknown\n /**\n * Content at the end of the field, which replaces `iconEnd`. It is hidden while\n * the field is loading, the spinner taking that place.\n */\n end?(): unknown\n}>()\n\n/**\n * The value, typed as text OR a number rather than text alone. On an\n * `<input type=\"number\">` Vue converts the value to a number by itself, so a\n * string-only model would hand a number back to a consumer who passed a string in,\n * and Vue would report an invalid prop.\n */\nconst model = defineModel<string | number>({ default: '' })\n\n/**\n * The value seen as text. Every measurement of its length — the counter, the soft\n * limit, whether the clear cross has anything to clear — goes through this, since a\n * number has no length of its own.\n */\nconst modelText = computed(() => String(model.value ?? ''))\n\nconst { attrs, rootClass, rootStyle, forwardedAttrs: restAttrs } = useRootAttrs()\n\n// A VInputGroup joins several controls into one object, so the shape of this field is the\n// row's decision rather than its own: a segment of another height stops lining up with its\n// neighbours and the merged border no longer reads as a single box. `disabled` is the one\n// read as an OR, the two answers being cumulative (VInput/context.ts).\nconst group = inject(inputGroupKey, null)\nconst {\n size: resolvedSize,\n compact: resolvedCompact,\n disabled: resolvedDisabled,\n} = useControlShape(props, group)\n\n// The prop keeps priority; what it falls back to is the dictionary, so the default\n// wording follows the language the design system is set to.\nconst m = useMessages()\nconst resolvedClearLabel = computed(() => props.clearLabel ?? m.value.common.clear)\n\nconst { fieldId, hintId, counterId, describedBy } = useFieldIds(\n attrs,\n () => !!props.hint,\n () => props.counter,\n)\n\nconst { hasIconStartHandler, hasIconEndHandler } = useIconClickHandlers({\n name: 'VInput',\n iconStartLabel: props.iconStartLabel,\n iconEndLabel: props.iconEndLabel,\n})\n\nconst controlEl = ref<HTMLInputElement | null>(null)\n\nconst { showClear, onClear } = useClearable({\n clearable: () => props.clearable,\n clearVisible: () => props.clearVisible,\n disabled: () => resolvedDisabled.value,\n readonly: () => props.readonly,\n text: () => modelText.value,\n controlEl,\n onCleared: () => {\n model.value = ''\n emit('clear')\n },\n})\n\n// The counter and the soft limit both measure `modelText` rather than the model\n// itself, a number having no length.\nconst { counterText, over } = useTextLimit({\n el: controlEl,\n text: () => modelText.value,\n maxlength: () => props.maxlength,\n softLimit: () => props.softLimit,\n})\n\n// The real input sits inside the wrapper, out of reach of whoever renders this component.\n// These three are how the components built on it get there — VCombobox refocuses the field\n// and selects its text this way.\ndefineExpose({\n /** Moves the focus to the real input. */\n focus: (options?: FocusOptions) => controlEl.value?.focus(options),\n /** Selects everything in the field, as VCombobox does when it reopens its panel. */\n select: () => controlEl.value?.select(),\n /** The real `<input>`, for what neither of the two above covers. */\n el: controlEl,\n})\n</script>\n\n<template>\n <div\n class=\"v-input v-control\"\n :class=\"rootClass\"\n :style=\"rootStyle\"\n :data-size=\"resolvedSize\"\n :data-compact=\"resolvedCompact ? '' : undefined\"\n :data-disabled=\"resolvedDisabled ? '' : undefined\"\n :data-readonly=\"readonly ? '' : undefined\"\n >\n <VTypography v-if=\"label\" as=\"label\" variant=\"label\" class=\"v-input-label\" :for=\"fieldId\">\n {{ label }}\n </VTypography>\n\n <div class=\"v-input-field\">\n <!-- TRAP — the start icon is rendered BEFORE the slot, where the end icon is the\n slot's own fallback. That asymmetry is what the composed fields need: VCombobox\n and VFileInput fill `#start` with the chips standing for their values, which is\n OTHER content in the same zone rather than another way of drawing the icon. As a\n fallback the icon would vanish the moment a value was chosen — silently, and the\n clickable variant with it. The end zone has no such consumer: what is put there\n really does replace the icon. -->\n <button\n v-if=\"iconStart && hasIconStartHandler\"\n type=\"button\"\n class=\"v-input-action v-field-action v-input-icon-start\"\n :aria-label=\"iconStartLabel ?? iconName(iconStart)\"\n :disabled=\"resolvedDisabled\"\n @click=\"emit('click:icon-start', $event)\"\n >\n <VIcon v-bind=\"iconProps(iconStart)\" />\n </button>\n <VIcon v-else-if=\"iconStart\" v-bind=\"iconProps(iconStart)\" class=\"v-input-icon-start\" />\n <slot name=\"start\" />\n\n <input\n :id=\"fieldId\"\n ref=\"controlEl\"\n v-model=\"model\"\n :aria-invalid=\"invalid || undefined\"\n v-bind=\"restAttrs\"\n class=\"v-input-control\"\n :type=\"type\"\n :maxlength=\"softLimit ? undefined : maxlength\"\n :disabled=\"resolvedDisabled\"\n :readonly=\"readonly || noTyping || undefined\"\n :aria-describedby=\"describedBy\"\n />\n\n <span\n v-if=\"counter\"\n :id=\"counterId\"\n class=\"v-input-counter v-field-counter\"\n :data-over=\"over ? '' : undefined\"\n >\n {{ counterText }}\n </span>\n\n <!-- Before the clear cross rather than after it, so that a consumer's control reads\n and tabs in the same order: what acts on the value sits next to the value, and\n the field's own controls stay together at the end. -->\n <slot name=\"value-end\" />\n\n <button\n v-if=\"showClear\"\n type=\"button\"\n class=\"v-input-action v-field-action v-input-clear\"\n :aria-label=\"resolvedClearLabel\"\n @click=\"onClear\"\n >\n <VIcon :name=\"closeIcon\" />\n </button>\n\n <VSpinner v-if=\"loading\" :label=\"loadingText\" />\n <slot v-else name=\"end\">\n <button\n v-if=\"iconEnd && hasIconEndHandler\"\n type=\"button\"\n class=\"v-input-action v-field-action v-input-icon-end\"\n :aria-label=\"iconEndLabel ?? iconName(iconEnd)\"\n :disabled=\"resolvedDisabled\"\n @click=\"emit('click:icon-end', $event)\"\n >\n <VIcon v-bind=\"iconProps(iconEnd)\" />\n </button>\n <VIcon v-else-if=\"iconEnd\" v-bind=\"iconProps(iconEnd)\" class=\"v-input-icon-end\" />\n </slot>\n </div>\n\n <VTypography v-if=\"hint\" :id=\"hintId\" variant=\"caption\" tone=\"muted\" class=\"v-input-hint\">\n {{ hint }}\n </VTypography>\n </div>\n</template>\n\n<style>\n@layer vectis.components {\n .v-input {\n display: flex;\n flex-direction: column;\n gap: var(--vectis-space-1);\n width: 100%;\n font-family: var(--vectis-text-family);\n }\n\n /* The label and the hint are rendered by VTypography, which carries their type. The\n .v-input-label and .v-input-hint classes remain as hooks: a consumer overrides\n through them, and the disabled state below reaches them that way. */\n\n /* This is the box that carries the border, the background and the focus ring.\n `--field-border-color` is the single source of truth for its colour, and the\n hover, error and disabled states do nothing but redefine it.\n\n Nothing here restates the size scale: the `--control-*` variables are inherited\n from the v-control root (styles/control-size.css), the icon context included. */\n .v-input-field {\n --field-border-color: var(--vectis-color-border-strong);\n\n display: flex;\n align-items: center;\n gap: var(--control-gap);\n height: var(--control-height);\n padding-inline: var(--control-padding-inline-field);\n background: var(--vectis-color-surface);\n color: var(--vectis-color-text);\n border: 1px solid var(--field-border-color);\n border-radius: var(--vectis-radius-interactive);\n font-size: var(--control-font-size);\n transition:\n border-color var(--vectis-duration-fast) var(--vectis-ease-default),\n background-color var(--vectis-duration-fast) var(--vectis-ease-default),\n box-shadow var(--vectis-duration-fast) var(--vectis-ease-default);\n }\n\n .v-input-control {\n flex: 1;\n min-width: 0;\n height: 100%;\n padding: 0;\n border: none;\n background: none;\n color: inherit;\n font: inherit;\n outline: none; /* the focus ring is drawn by the field around it, not here */\n }\n\n .v-input-control::placeholder {\n color: var(--vectis-color-text-subtle);\n }\n\n /* The browsers' own in-field controls are removed, because the design system draws\n its own: WebKit's search cross would sit beside the `clearable` one, the number\n spinners and Edge's password-reveal eye would clash with the icons. */\n .v-input-control::-webkit-search-cancel-button,\n .v-input-control::-webkit-search-decoration,\n .v-input-control::-webkit-search-results-button,\n .v-input-control::-webkit-search-results-decoration,\n .v-input-control::-webkit-inner-spin-button,\n .v-input-control::-webkit-outer-spin-button {\n -webkit-appearance: none;\n appearance: none;\n }\n\n .v-input-control[type='number'] {\n appearance: textfield;\n }\n\n .v-input-control::-ms-reveal {\n display: none;\n }\n\n /* The yellow autofill background is painted by the browser on the input itself,\n inside our field. Its colour cannot be changed, but matching the field's radius\n at least stops it from showing square corners inside a rounded box. */\n .v-input-control:-webkit-autofill {\n border-radius: var(--vectis-radius-interactive);\n }\n\n /* A read-only field sinks slightly into the page but keeps its text at full\n strength — the value is there to be read — and still shows the accent focus ring.\n The state is read from [data-readonly] and never from `:read-only`, which the\n browser also matches on a disabled field.\n\n TRAP — this block must stay FIRST in the sequence of states: read-only, then\n hover, focus, invalid and disabled. The read-only, focus, invalid and disabled\n selectors all weigh (0,3,0), `:has()` taking the specificity of what it contains, so\n nothing but the source order arbitrates between them. Hover alone weighs more, (0,6,0):\n its `:not()` keeps it off a focused, invalid or disabled field, and on a read-only\n one it darkens the border as on any other. Moved further down, this rule would repaint the\n error border and the accent focus ring grey, with no error anywhere. What\n belongs to a read-only field is its BASE colour alone. */\n .v-input[data-readonly] .v-input-field {\n --field-border-color: var(--vectis-color-border);\n\n background: var(--vectis-color-surface-sunken);\n }\n\n .v-input-field:hover:not(:has(.v-input-control:focus)):not(\n :has(\n .v-input-control:disabled,\n .v-input-control:user-invalid,\n .v-input-control[aria-invalid='true']\n )\n ) {\n --field-border-color: color-mix(\n in oklab,\n var(--vectis-color-border-strong),\n var(--vectis-color-text) 15%\n );\n }\n\n /* The focused field appears to have a two-pixel border: it is really its own 1px\n border plus a 1px shadow of the same colour just outside it, which costs no\n layout and therefore makes nothing jump.\n\n The selector watches the CONTROL's focus and not `:focus-within`, so that when\n one of the field's own buttons — the cross, a clickable icon — takes keyboard\n focus, only that button's outline lights up: two indicators at once would be\n unreadable. And it is `:focus` rather than `:focus-visible`, because a text field\n shows its focus even when it was reached with the mouse.\n\n The transparent outline is the safety net for Windows forced colours, which drop\n box-shadows entirely; an outline survives and keeps the field marked. */\n .v-input-field:has(.v-input-control:focus) {\n --field-border-color: var(--vectis-color-accent);\n\n box-shadow: 0 0 0 1px var(--field-border-color);\n outline: var(--vectis-focus-ring-width) solid transparent;\n }\n\n /* The invalid state, from the browser's own verdict first and from the `invalid`\n prop second. Only the colour variable is changed, which is why the border AND\n the focus ring both turn red without either being restated. */\n .v-input-field:has(.v-input-control:user-invalid),\n .v-input-field:has(.v-input-control[aria-invalid='true']) {\n --field-border-color: var(--vectis-color-danger);\n }\n\n /* The counter's type and its overflow colour are `.v-field-counter`'s (styles/field.css),\n shared with the counters VTextarea and VFileInput draw under their field. Inside the\n box it must not shrink, or a long value would squeeze its figures. */\n .v-input-counter {\n flex: none;\n }\n\n /* A decorative icon is drawn in a muted grey, so it stays quieter than the text\n being typed beside it. */\n .v-input-field > .v-icon {\n color: var(--vectis-color-text-muted);\n }\n\n .v-input-field > .v-spinner {\n font-size: var(--vectis-icon-size);\n }\n\n /* A disabled field greys out through the colour tokens, the same ones VCheckbox and\n VRadio use, and never through opacity. It comes LAST in the sequence of states,\n which at equal specificity is what makes it win over all of them, the error\n included: a disabled field is not submitted, so it has nothing to report.\n\n The text here is `text-muted` and not the `text-subtle` used by the label and the\n hint below, because those sit on the page surface where this sits on\n `surface-muted` — against which subtle falls to a 4.4:1 contrast. The control\n itself is exempt from that rule, being natively disabled, but the icons the field\n CONTAINS are not, and they inherit this colour. */\n .v-input[data-disabled] .v-input-field {\n --field-border-color: var(--vectis-color-border);\n\n background: var(--vectis-color-surface-muted);\n color: var(--vectis-color-text-muted);\n cursor: not-allowed;\n }\n\n .v-input[data-disabled] .v-input-label,\n .v-input[data-disabled] .v-input-hint,\n .v-input[data-disabled] .v-input-counter {\n color: var(--vectis-color-text-subtle);\n }\n\n .v-input[data-disabled] .v-input-action,\n .v-input[data-disabled] .v-input-field > .v-icon {\n color: inherit;\n cursor: not-allowed;\n }\n\n .v-input-control:disabled {\n cursor: not-allowed;\n }\n\n /* Two arrangements a field composed on top of this one opts into with a class on this\n root: VCombobox and VFileInput, the two that put chips inside the field. They live in\n THIS sheet because it is the one both are guaranteed to load, being built on VInput —\n the core sheet would charge every consumer for them.\n\n `.v-input-end-pinned` lifts the clear cross and whatever carries `.v-input-icon-end` —\n the end icon, or a composed field's own chevron and spinner — out of the flow, so they\n stay pinned to the end of the field and vertically centred whatever the chips do. The\n room they occupy is then reserved with padding, or the text and the chips would run\n underneath. That reservation is written as exactly what the flow would have produced\n with them left in place — one glyph's width each from the field's padding, one gap\n between two of them — and reads the same two variables as the insets below, so the room\n reserved and the glyphs it protects cannot drift apart.\n\n Whether the cross takes its room is read off the markup (`:has`): the cross is rendered\n exactly when there is something to clear, so the padding cannot disagree with it.\n\n The centring is a translation kept SEPARATE from `rotate`, which VCombobox's chevron\n turns when its panel opens. */\n .v-input-end-pinned .v-input-field {\n position: relative;\n padding-inline-end: calc(\n var(--control-padding-inline-field) + var(--vectis-icon-size) + var(--control-gap)\n );\n }\n\n .v-input-end-pinned .v-input-field:has(> .v-input-clear) {\n padding-inline-end: calc(\n var(--control-padding-inline-field) + 2 * var(--vectis-icon-size) + 2 * var(--control-gap)\n );\n }\n\n .v-input-end-pinned :is(.v-input-clear, .v-input-icon-end) {\n position: absolute;\n inset-inline-end: var(--control-padding-inline-field);\n top: 50%;\n translate: 0 -50%;\n }\n\n /* TRAP — this inset reads in GLYPHS and not in buttons. The cross is one of the field's\n buttons, whose negative margin — half the difference between icon and button — already\n cancels its own overhang, so an inset lands on the glyph's edge. Measured in button\n widths instead, it pushes the cross a whole gap too far, with nothing to signal it.\n It overrides the rule above at the same weight, so it has to stay after it. */\n .v-input-end-pinned .v-input-clear {\n inset-inline-end: calc(\n var(--control-padding-inline-field) + var(--vectis-icon-size) + var(--control-gap)\n );\n }\n\n /* `.v-input-chips` lets the chips WRAP onto several rows, the field growing instead of\n scrolling but never shrinking below an ordinary control.\n\n The input is forced to the chips' height rather than the full height it inherits: its\n natural height is greater than a chip's, and the field would grow the moment it was\n focused. That height is `--chip-height`, written inline on the composed field's root\n by the helper that also picks the chips' size (utils/chip.ts), so the two cannot drift.\n\n TRAP — the control rule must stay at (0,2,0). VCombobox folds its search input away\n with a (0,3,0) rule that zeroes this height, and at equal weight the winner would be\n whichever of the two sheets a bundler put last. */\n .v-input-chips .v-input-field {\n flex-wrap: wrap;\n height: auto;\n min-height: var(--control-height);\n padding-block: var(--vectis-space-1);\n }\n\n .v-input-chips .v-input-control {\n height: var(--chip-height);\n }\n\n @media (prefers-reduced-motion: reduce) {\n .v-input-field {\n transition: none;\n }\n }\n}\n</style>\n"],"mappings":"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"}
1
+ {"version":3,"file":"VInput.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../../src/components/VInput/VInput.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @core\n/**\n * A complete text field: label above, hint below, icons inside (clickable ones included), a\n * counter, a clear cross and a loading state, all arranged around a real `<input>`.\n *\n * Wrapper-root, so the attrs split: `class`/`style` stay on the root, where a consumer\n * expects to style the field, and everything else is forwarded to the input, where `name`,\n * `required` and the `aria-*` actually do something.\n *\n * Validation stays the browser's. The field turns red through `:user-invalid`, which only\n * reacts once the reader has left it, and even the soft limit is reported by\n * `setCustomValidity` rather than an event of our own — so a consumer's `<form>` sees\n * exactly what a bare input would give it.\n *\n * The behavioural JS is the v-model and the clear cross, which has to move focus back to the\n * field: it disappears the moment it is clicked, and focus would otherwise fall to the page.\n */\nimport { computed, inject, ref } from 'vue'\n\nimport VIcon from '../VIcon/VIcon.vue'\nimport { iconName, iconProps } from '../VIcon/iconProps'\nimport { close as closeIcon } from '../VIcon/icons/close'\nimport type { IconSource } from '../VIcon/types'\nimport VSpinner from '../VSpinner/VSpinner.vue'\nimport VTypography from '../VTypography/VTypography.vue'\n\nimport { useFieldIds } from '../../composables/useFieldIds'\nimport { useIconClickHandlers } from '../../composables/useIconClickHandlers'\nimport { useClearable } from '../../composables/useClearable'\nimport { useControlShape } from '../../composables/useControlShape'\nimport { useRootAttrs } from '../../composables/useRootAttrs'\nimport { useTextLimit } from '../../composables/useTextLimit'\nimport { useMessages } from '../../i18n/state'\n\nimport { inputGroupKey } from './context'\n\n/** The height of the field: 32, 40 or 48 pixels. */\nexport type InputSize = 'sm' | 'md' | 'lg'\n\n/** The native type of the input, which also decides the keyboard a phone offers. */\nexport type InputType = 'text' | 'email' | 'number' | 'password' | 'search' | 'tel' | 'url'\n\ninterface InputProps {\n /** The height of the field: 32, 40 or 48 pixels. */\n size?: InputSize\n /** Takes 4px off the height, leaving the padding, the text and the icons as they are. */\n compact?: boolean\n /**\n * The native type of the input, which is also what tells a phone which keyboard to\n * offer: a numeric pad for `number`, an @ key for `email`.\n */\n type?: InputType\n /**\n * Marks the field as invalid whatever the browser thinks. This is the route for a\n * rule only the server can check; anything the browser can validate on its own\n * already colours the field without this prop.\n */\n invalid?: boolean\n /** Makes the field unusable, greyed out through the colour tokens. */\n disabled?: boolean\n /**\n * Shows the value without allowing it to be changed. The field can still be\n * focused and copied from, and it hides the clear button, unless `clearVisible`\n * answers that question explicitly.\n */\n readonly?: boolean\n /**\n * Refuses the keyboard without drawing the field as read-only: the native attribute is\n * set, but the field keeps its ordinary look and its clear cross. For a field whose value\n * comes from somewhere else, a picker or a file dialog, and which is no less editable for\n * it.\n */\n noTyping?: boolean\n /** The label above the field, tied to it so that clicking it focuses the field. */\n label?: string\n /**\n * A line of help under the field. It is tied to the input for assistive\n * technology, so it is read out along with the label.\n */\n hint?: string\n /**\n * An icon inside the field, at the start. It is decorative by default and becomes\n * a real button as soon as a `@click:icon-start` listener is attached, in which\n * case it needs `iconStartLabel`. The `#start` slot is rendered after it, so a\n * field composed on top of this one can show both.\n */\n iconStart?: IconSource\n /**\n * The same at the end of the field. The `#end` slot replaces it, and the loading\n * spinner takes its place while it turns.\n */\n iconEnd?: IconSource\n /** What the start icon does, in words, once it is clickable. */\n iconStartLabel?: string\n /** What the end icon does, in words, once it is clickable. */\n iconEndLabel?: string\n /** Shows a spinner at the end of the field, in place of the end icon or slot. */\n loading?: boolean\n /**\n * What screen readers announce while the spinner turns. It falls back to the\n * design system dictionary.\n */\n loadingText?: string\n /**\n * Offers a cross that empties the field. It appears when there is something to\n * clear and the field can be edited.\n */\n clearable?: boolean\n /**\n * Decides whether the cross is shown, instead of letting the field work it out\n * from its own content (a read-only field included).\n *\n * It exists for the components built on top of this one, where what there is to\n * clear is not the text: VCombobox holds its selection as chips beside the field,\n * and a read-only date or time picker changes its value through a panel rather\n * than by typing.\n */\n clearVisible?: boolean\n /** What the clear button does, in words. It falls back to the design system dictionary. */\n clearLabel?: string\n /**\n * The maximum number of characters. By default this is the browser's own limit,\n * which simply refuses anything beyond it.\n */\n maxlength?: number\n /**\n * Turns that limit into a soft one: the reader may type past it, and the field\n * goes into error instead of silently refusing the keystrokes. It is reported\n * through the native validity, so a form cannot be submitted over the limit.\n */\n softLimit?: boolean\n /**\n * Shows how much has been typed, at the end of the field: \"12/80\" against a limit,\n * or just \"12\" without one.\n */\n counter?: boolean\n}\n\ndefineOptions({ inheritAttrs: false })\n\nconst props = withDefaults(defineProps<InputProps>(), {\n size: 'md',\n compact: false,\n type: 'text',\n invalid: false,\n disabled: false,\n readonly: false,\n label: undefined,\n hint: undefined,\n iconStart: undefined,\n iconEnd: undefined,\n iconStartLabel: undefined,\n iconEndLabel: undefined,\n loading: false,\n loadingText: undefined,\n clearable: false,\n clearVisible: undefined,\n clearLabel: undefined,\n maxlength: undefined,\n softLimit: false,\n noTyping: false,\n counter: false,\n})\n\nconst emit = defineEmits<{\n /** The start icon was clicked. Attaching this listener is what makes it a button. */\n 'click:icon-start': [event: MouseEvent]\n /** The end icon was clicked. Attaching this listener is what makes it a button. */\n 'click:icon-end': [event: MouseEvent]\n /** The clear button was pressed. The field has already been emptied. */\n clear: []\n}>()\n\ndefineSlots<{\n /**\n * Content at the start of the field, rendered after `iconStart` rather than in its\n * place, which is what lets a field composed on top of this one put the chips\n * standing for its values here beside an icon it still wants drawn.\n */\n start?(): unknown\n /**\n * Controls of your own inside the field, placed before the field's own: the clear\n * cross and the end icon. It is the slot for something that acts on the VALUE, such\n * as the AM/PM button of a 12-hour time field, which belongs beside the text rather\n * than past the controls that clear and open.\n */\n 'value-end'?(): unknown\n /**\n * Content at the end of the field, which replaces `iconEnd`. It is hidden while\n * the field is loading, the spinner taking that place.\n */\n end?(): unknown\n}>()\n\n/**\n * The value, typed as text OR a number rather than text alone. On an\n * `<input type=\"number\">` Vue converts the value to a number by itself, so a\n * string-only model would hand a number back to a consumer who passed a string in,\n * and Vue would report an invalid prop.\n */\nconst model = defineModel<string | number>({ default: '' })\n\n/**\n * The value seen as text. Every measurement of its length — the counter, the soft\n * limit, whether the clear cross has anything to clear — goes through this, since a\n * number has no length of its own.\n */\nconst modelText = computed(() => String(model.value ?? ''))\n\nconst { attrs, rootClass, rootStyle, forwardedAttrs: restAttrs } = useRootAttrs()\n\n// A VInputGroup joins several controls into one object, so the shape of this field is the\n// row's decision rather than its own: a segment of another height stops lining up with its\n// neighbours and the merged border no longer reads as a single box. `disabled` is the one\n// read as an OR, the two answers being cumulative (VInput/context.ts).\nconst group = inject(inputGroupKey, null)\nconst {\n size: resolvedSize,\n compact: resolvedCompact,\n disabled: resolvedDisabled,\n} = useControlShape(props, group)\n\n// The prop keeps priority; what it falls back to is the dictionary, so the default\n// wording follows the language the design system is set to.\nconst m = useMessages()\nconst resolvedClearLabel = computed(() => props.clearLabel ?? m.value.common.clear)\n\nconst { fieldId, hintId, counterId, describedBy } = useFieldIds(\n attrs,\n () => !!props.hint,\n () => props.counter,\n)\n\nconst { hasIconStartHandler, hasIconEndHandler } = useIconClickHandlers({\n name: 'VInput',\n iconStartLabel: props.iconStartLabel,\n iconEndLabel: props.iconEndLabel,\n})\n\nconst controlEl = ref<HTMLInputElement | null>(null)\n\nconst { showClear, onClear } = useClearable({\n clearable: () => props.clearable,\n clearVisible: () => props.clearVisible,\n disabled: () => resolvedDisabled.value,\n readonly: () => props.readonly,\n text: () => modelText.value,\n controlEl,\n onCleared: () => {\n model.value = ''\n emit('clear')\n },\n})\n\n// The counter and the soft limit both measure `modelText` rather than the model\n// itself, a number having no length.\nconst { counterText, over } = useTextLimit({\n el: controlEl,\n text: () => modelText.value,\n maxlength: () => props.maxlength,\n softLimit: () => props.softLimit,\n})\n\n// The real input sits inside the wrapper, out of reach of whoever renders this component.\n// These three are how the components built on it get there — VCombobox refocuses the field\n// and selects its text this way.\ndefineExpose({\n /** Moves the focus to the real input. */\n focus: (options?: FocusOptions) => controlEl.value?.focus(options),\n /** Selects everything in the field, as VCombobox does when it reopens its panel. */\n select: () => controlEl.value?.select(),\n /** The real `<input>`, for what neither of the two above covers. */\n el: controlEl,\n})\n</script>\n\n<template>\n <div\n class=\"v-input v-control\"\n :class=\"rootClass\"\n :style=\"rootStyle\"\n :data-size=\"resolvedSize\"\n :data-compact=\"resolvedCompact ? '' : undefined\"\n :data-disabled=\"resolvedDisabled ? '' : undefined\"\n :data-readonly=\"readonly ? '' : undefined\"\n >\n <VTypography v-if=\"label\" as=\"label\" variant=\"label\" class=\"v-input-label\" :for=\"fieldId\">\n {{ label }}\n </VTypography>\n\n <div class=\"v-input-field\">\n <!-- TRAP — the start icon is rendered BEFORE the slot, where the end icon is the\n slot's own fallback. That asymmetry is what the composed fields need: VCombobox\n and VFileInput fill `#start` with the chips standing for their values, which is\n OTHER content in the same zone rather than another way of drawing the icon. As a\n fallback the icon would vanish the moment a value was chosen — silently, and the\n clickable variant with it. The end zone has no such consumer: what is put there\n really does replace the icon. -->\n <button\n v-if=\"iconStart && hasIconStartHandler\"\n type=\"button\"\n class=\"v-input-action v-field-action v-input-icon-start\"\n :aria-label=\"iconStartLabel ?? iconName(iconStart)\"\n :disabled=\"resolvedDisabled\"\n @click=\"emit('click:icon-start', $event)\"\n >\n <VIcon v-bind=\"iconProps(iconStart)\" />\n </button>\n <VIcon v-else-if=\"iconStart\" v-bind=\"iconProps(iconStart)\" class=\"v-input-icon-start\" />\n <slot name=\"start\" />\n\n <input\n :id=\"fieldId\"\n ref=\"controlEl\"\n v-model=\"model\"\n :aria-invalid=\"invalid || undefined\"\n v-bind=\"restAttrs\"\n class=\"v-input-control\"\n :type=\"type\"\n :maxlength=\"softLimit ? undefined : maxlength\"\n :disabled=\"resolvedDisabled\"\n :readonly=\"readonly || noTyping || undefined\"\n :aria-describedby=\"describedBy\"\n />\n\n <span\n v-if=\"counter\"\n :id=\"counterId\"\n class=\"v-input-counter v-field-counter\"\n :data-over=\"over ? '' : undefined\"\n >\n {{ counterText }}\n </span>\n\n <!-- Before the clear cross rather than after it, so that a consumer's control reads\n and tabs in the same order: what acts on the value sits next to the value, and\n the field's own controls stay together at the end. -->\n <slot name=\"value-end\" />\n\n <button\n v-if=\"showClear\"\n type=\"button\"\n class=\"v-input-action v-field-action v-input-clear\"\n :aria-label=\"resolvedClearLabel\"\n @click=\"onClear\"\n >\n <VIcon :name=\"closeIcon\" />\n </button>\n\n <VSpinner v-if=\"loading\" :label=\"loadingText\" />\n <slot v-else name=\"end\">\n <button\n v-if=\"iconEnd && hasIconEndHandler\"\n type=\"button\"\n class=\"v-input-action v-field-action v-input-icon-end\"\n :aria-label=\"iconEndLabel ?? iconName(iconEnd)\"\n :disabled=\"resolvedDisabled\"\n @click=\"emit('click:icon-end', $event)\"\n >\n <VIcon v-bind=\"iconProps(iconEnd)\" />\n </button>\n <VIcon v-else-if=\"iconEnd\" v-bind=\"iconProps(iconEnd)\" class=\"v-input-icon-end\" />\n </slot>\n </div>\n\n <VTypography v-if=\"hint\" :id=\"hintId\" variant=\"caption\" tone=\"muted\" class=\"v-input-hint\">\n {{ hint }}\n </VTypography>\n </div>\n</template>\n\n<style>\n@layer vectis.components {\n .v-input {\n display: flex;\n flex-direction: column;\n gap: var(--vectis-space-1);\n width: 100%;\n font-family: var(--vectis-text-family);\n }\n\n /* The label and the hint are rendered by VTypography, which carries their type. The\n .v-input-label and .v-input-hint classes remain as hooks: a consumer overrides\n through them, and the disabled state below reaches them that way. */\n\n /* This is the box that carries the border, the background and the focus ring.\n `--field-border-color` is the single source of truth for its colour, and the\n hover, error and disabled states do nothing but redefine it.\n\n Nothing here restates the size scale: the `--control-*` variables are inherited\n from the v-control root (styles/control-size.css), the icon context included. */\n .v-input-field {\n --field-border-color: var(--vectis-color-border-strong);\n\n display: flex;\n align-items: center;\n gap: var(--control-gap);\n height: var(--control-height);\n padding-inline: var(--control-padding-inline-field);\n background: var(--vectis-color-surface);\n color: var(--vectis-color-text);\n border: 1px solid var(--field-border-color);\n border-radius: var(--vectis-radius-interactive);\n font-size: var(--control-font-size);\n transition:\n border-color var(--vectis-duration-fast) var(--vectis-ease-default),\n background-color var(--vectis-duration-fast) var(--vectis-ease-default),\n box-shadow var(--vectis-duration-fast) var(--vectis-ease-default);\n }\n\n .v-input-control {\n flex: 1;\n min-width: 0;\n height: 100%;\n padding: 0;\n border: none;\n background: none;\n color: inherit;\n font: inherit;\n outline: none; /* the focus ring is drawn by the field around it, not here */\n }\n\n .v-input-control::placeholder {\n color: var(--vectis-color-text-subtle);\n }\n\n /* The browsers' own in-field controls are removed, because the design system draws\n its own: WebKit's search cross would sit beside the `clearable` one, the number\n spinners and Edge's password-reveal eye would clash with the icons. */\n .v-input-control::-webkit-search-cancel-button,\n .v-input-control::-webkit-search-decoration,\n .v-input-control::-webkit-search-results-button,\n .v-input-control::-webkit-search-results-decoration,\n .v-input-control::-webkit-inner-spin-button,\n .v-input-control::-webkit-outer-spin-button {\n -webkit-appearance: none;\n appearance: none;\n }\n\n .v-input-control[type='number'] {\n appearance: textfield;\n }\n\n .v-input-control::-ms-reveal {\n display: none;\n }\n\n /* The yellow autofill background is painted by the browser on the input itself,\n inside our field. Its colour cannot be changed, but matching the field's radius\n at least stops it from showing square corners inside a rounded box. */\n .v-input-control:-webkit-autofill {\n border-radius: var(--vectis-radius-interactive);\n }\n\n /* A read-only field sinks slightly into the page but keeps its text at full\n strength — the value is there to be read — and still shows the accent focus ring.\n The state is read from [data-readonly] and never from `:read-only`, which the\n browser also matches on a disabled field.\n\n TRAP — this block must stay FIRST in the sequence of states: read-only, then\n hover, focus, invalid and disabled. The read-only, focus, invalid and disabled\n selectors all weigh (0,3,0), `:has()` taking the specificity of what it contains, so\n nothing but the source order arbitrates between them. Hover alone weighs more, (0,6,0):\n its `:not()` keeps it off a focused, invalid or disabled field, and on a read-only\n one it darkens the border as on any other. Moved further down, this rule would repaint the\n error border and the accent focus ring grey, with no error anywhere. What\n belongs to a read-only field is its BASE colour alone. */\n .v-input[data-readonly] .v-input-field {\n --field-border-color: var(--vectis-color-border);\n\n background: var(--vectis-color-surface-sunken);\n }\n\n .v-input-field:hover:not(:has(.v-input-control:focus)):not(\n :has(\n .v-input-control:disabled,\n .v-input-control:user-invalid,\n .v-input-control[aria-invalid='true']\n )\n ) {\n --field-border-color: color-mix(\n in oklab,\n var(--vectis-color-border-strong),\n var(--vectis-color-text) 15%\n );\n }\n\n /* The focused field appears to have a two-pixel border: it is really its own 1px\n border plus a 1px shadow of the same colour just outside it, which costs no\n layout and therefore makes nothing jump.\n\n The selector watches the CONTROL's focus and not `:focus-within`, so that when\n one of the field's own buttons — the cross, a clickable icon — takes keyboard\n focus, only that button's outline lights up: two indicators at once would be\n unreadable. And it is `:focus` rather than `:focus-visible`, because a text field\n shows its focus even when it was reached with the mouse.\n\n The transparent outline is the safety net for Windows forced colours, which drop\n box-shadows entirely; an outline survives and keeps the field marked. */\n .v-input-field:has(.v-input-control:focus) {\n --field-border-color: var(--vectis-color-accent);\n\n box-shadow: 0 0 0 1px var(--field-border-color);\n outline: var(--vectis-focus-ring-width) solid transparent;\n }\n\n /* The invalid state, from the browser's own verdict first and from the `invalid`\n prop second. Only the colour variable is changed, which is why the border AND\n the focus ring both turn red without either being restated. */\n .v-input-field:has(.v-input-control:user-invalid),\n .v-input-field:has(.v-input-control[aria-invalid='true']) {\n --field-border-color: var(--vectis-color-danger);\n }\n\n /* The counter's type and its overflow colour are `.v-field-counter`'s (styles/field.css),\n shared with the counters VTextarea and VFileInput draw under their field. Inside the\n box it must not shrink, or a long value would squeeze its figures. */\n .v-input-counter {\n flex: none;\n }\n\n /* A decorative icon is drawn in a muted grey, so it stays quieter than the text\n being typed beside it. */\n .v-input-field > .v-icon {\n color: var(--vectis-color-text-muted);\n }\n\n .v-input-field > .v-spinner {\n font-size: var(--vectis-icon-size);\n }\n\n /* A disabled field greys out through the colour tokens, the same ones VCheckbox and\n VRadio use, and never through opacity. It comes LAST in the sequence of states,\n which at equal specificity is what makes it win over all of them, the error\n included: a disabled field is not submitted, so it has nothing to report.\n\n The text here is `text-muted` and not the `text-subtle` used by the label and the\n hint below, because those sit on the page surface where this sits on\n `surface-muted` — against which subtle falls to a 4.4:1 contrast. The control\n itself is exempt from that rule, being natively disabled, but the icons the field\n CONTAINS are not, and they inherit this colour. */\n .v-input[data-disabled] .v-input-field {\n --field-border-color: var(--vectis-color-border);\n\n background: var(--vectis-color-surface-muted);\n color: var(--vectis-color-text-muted);\n cursor: not-allowed;\n }\n\n .v-input[data-disabled] .v-input-label,\n .v-input[data-disabled] .v-input-hint,\n .v-input[data-disabled] .v-input-counter {\n color: var(--vectis-color-text-subtle);\n }\n\n .v-input[data-disabled] .v-input-action,\n .v-input[data-disabled] .v-input-field > .v-icon {\n color: inherit;\n cursor: not-allowed;\n }\n\n .v-input-control:disabled {\n cursor: not-allowed;\n }\n\n /* Two arrangements a field composed on top of this one opts into with a class on this\n root: VCombobox and VFileInput, the two that put chips inside the field. They live in\n THIS sheet because it is the one both are guaranteed to load, being built on VInput —\n the core sheet would charge every consumer for them.\n\n `.v-input-end-pinned` lifts the clear cross and whatever carries `.v-input-icon-end` —\n the end icon, or a composed field's own chevron and spinner — out of the flow, so they\n stay pinned to the end of the field and vertically centred whatever the chips do. The\n room they occupy is then reserved with padding, or the text and the chips would run\n underneath. That reservation is written as exactly what the flow would have produced\n with them left in place — one glyph's width each from the field's padding, one gap\n between two of them — and reads the same two variables as the insets below, so the room\n reserved and the glyphs it protects cannot drift apart.\n\n Which of the two take their room is read off the markup (`:has`): the cross is rendered\n exactly when there is something to clear, and the end icon only when there is one (a\n VCombobox with `hideExpandIcon` has none), so the padding cannot disagree with either.\n VInput's own loading spinner counts as the end icon it replaces.\n\n The centring is a translation kept SEPARATE from `rotate`, which VCombobox's chevron\n turns when its panel opens. */\n .v-input-end-pinned .v-input-field {\n position: relative;\n }\n\n .v-input-end-pinned .v-input-field:has(> :is(.v-input-clear, .v-input-icon-end, .v-spinner)) {\n padding-inline-end: calc(\n var(--control-padding-inline-field) + var(--vectis-icon-size) + var(--control-gap)\n );\n }\n\n .v-input-end-pinned\n .v-input-field:has(> .v-input-clear):has(> :is(.v-input-icon-end, .v-spinner)) {\n padding-inline-end: calc(\n var(--control-padding-inline-field) + 2 * var(--vectis-icon-size) + 2 * var(--control-gap)\n );\n }\n\n .v-input-end-pinned :is(.v-input-clear, .v-input-icon-end) {\n position: absolute;\n inset-inline-end: var(--control-padding-inline-field);\n top: 50%;\n translate: 0 -50%;\n }\n\n /* TRAP — this inset reads in GLYPHS and not in buttons. The cross is one of the field's\n buttons, whose negative margin — half the difference between icon and button — already\n cancels its own overhang, so an inset lands on the glyph's edge. Measured in button\n widths instead, it pushes the cross a whole gap too far, with nothing to signal it.\n It applies only when an end icon follows the cross: alone, the cross takes the end\n slot itself, at the inset above. */\n .v-input-end-pinned .v-input-clear:has(~ :is(.v-input-icon-end, .v-spinner)) {\n inset-inline-end: calc(\n var(--control-padding-inline-field) + var(--vectis-icon-size) + var(--control-gap)\n );\n }\n\n /* `.v-input-chips` lets the chips WRAP onto several rows, the field growing instead of\n scrolling but never shrinking below an ordinary control.\n\n The input is forced to the chips' height rather than the full height it inherits: its\n natural height is greater than a chip's, and the field would grow the moment it was\n focused. That height is `--chip-height`, written inline on the composed field's root\n by the helper that also picks the chips' size (utils/chip.ts), so the two cannot drift.\n\n TRAP — the control rule must stay at (0,2,0). VCombobox folds its search input away\n with a (0,3,0) rule that zeroes this height, and at equal weight the winner would be\n whichever of the two sheets a bundler put last. */\n .v-input-chips .v-input-field {\n flex-wrap: wrap;\n height: auto;\n min-height: var(--control-height);\n padding-block: var(--vectis-space-1);\n }\n\n .v-input-chips .v-input-control {\n height: var(--chip-height);\n }\n\n @media (prefers-reduced-motion: reduce) {\n .v-input-field {\n transition: none;\n }\n }\n}\n</style>\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA6IA,IAAM,IAAQ,GAwBR,IAAO,GAoCP,IAAQ,EAA4B,GAAA,YAAgB,GAOpD,IAAY,QAAe,OAAO,EAAM,SAAS,EAAE,CAAC,GAEpD,EAAE,UAAO,cAAW,cAAW,gBAAgB,MAAc,EAAa,GAO1E,EACJ,MAAM,GACN,SAAS,IACT,UAAU,MACR,EAAgB,GALN,EAAO,IAAe,IAKT,CAAK,GAI1B,KAAI,EAAY,GAChB,KAAqB,QAAe,EAAM,cAAc,GAAE,MAAM,OAAO,KAAK,GAE5E,EAAE,YAAS,YAAQ,eAAW,oBAAgB,GAClD,SACM,CAAC,CAAC,EAAM,YACR,EAAM,OACd,GAEM,EAAE,yBAAqB,0BAAsB,GAAqB;GACtE,MAAM;GACN,gBAAgB,EAAM;GACtB,cAAc,EAAM;EACtB,CAAC,GAEK,IAAY,GAA6B,IAAI,GAE7C,EAAE,eAAW,eAAY,GAAa;GAC1C,iBAAiB,EAAM;GACvB,oBAAoB,EAAM;GAC1B,gBAAgB,EAAiB;GACjC,gBAAgB,EAAM;GACtB,YAAY,EAAU;GACtB;GACA,iBAAiB;IAEf,AADA,EAAM,QAAQ,IACd,EAAK,OAAO;GACd;EACF,CAAC,GAIK,EAAE,iBAAa,aAAS,EAAa;GACzC,IAAI;GACJ,YAAY,EAAU;GACtB,iBAAiB,EAAM;GACvB,iBAAiB,EAAM;EACzB,CAAC;SAKD,EAAa;GAEX,QAAQ,MAA2B,EAAU,OAAO,MAAM,CAAO;GAEjE,cAAc,EAAU,OAAO,OAAO;GAEtC,IAAI;EACN,CAAC,mBAIC,EA2FM,OAAA;GA1FJ,OAAK,EAAA,CAAC,qBACE,EAAA,CAAA,CAAS,CAAA;GAChB,OAAK,EAAE,EAAA,CAAA,CAAS;GAChB,aAAW,EAAA,CAAA;GACX,gBAAc,EAAA,EAAA,IAAe,KAAQ,KAAA;GACrC,iBAAe,EAAA,CAAA,IAAgB,KAAQ,KAAA;GACvC,iBAAe,EAAA,WAAQ,KAAQ,KAAA;;GAEb,EAAA,SAAA,EAAA,GAAnB,EAEc,GAAA;;IAFY,IAAG;IAAQ,SAAQ;IAAQ,OAAM;IAAiB,KAAK,EAAA,CAAA;;qBACpE,CAAA,EAAA,EAAR,EAAA,KAAK,GAAA,CAAA,CAAA,CAAA;;;GAGV,EAyEM,OAzEN,GAyEM;IAhEI,EAAA,aAAa,EAAA,EAAA,KAAA,EAAA,GADrB,EASS,UAAA;;KAPP,MAAK;KACL,OAAM;KACL,cAAY,EAAA,kBAAkB,EAAA,CAAA,CAAQ,CAAC,EAAA,SAAS;KAChD,UAAU,EAAA,CAAA;KACV,SAAK,AAAA,EAAA,QAAA,MAAE,EAAI,oBAAqB,CAAM;QAEvC,EAAuC,GAAA,EAAA,EAAxB,EAAA,CAAA,CAAS,CAAC,EAAA,SAAS,CAAA,CAAA,GAAA,MAAA,EAAA,CAAA,GAAA,GAAA,CAAA,KAElB,EAAA,aAAA,EAAA,GAAlB,EAAwF,GAAxF,EAAwF,EAAA,KAAA,EAAA,GAAnD,EAAA,CAAA,CAAS,CAAC,EAAA,SAAS,GAAA,EAAG,OAAM,qBAAoB,CAAA,GAAA,MAAA,EAAA,KAAA,EAAA,IAAA,EAAA;IACrF,EAAqB,EAAA,QAAA,OAAA;MAErB,EAYE,SAZF,EAYE;KAXC,IAAI,EAAA,CAAA;cACD;KAAJ,KAAI;8CACU,QAAA;KACb,gBAAc,EAAA,WAAW,KAAA;OAClB,EAAA,CAAA,GAAS;KACjB,OAAM;KACL,MAAM,EAAA;KACN,WAAW,EAAA,YAAY,KAAA,IAAY,EAAA;KACnC,UAAU,EAAA,CAAA;KACV,UAAU,EAAA,YAAY,EAAA,YAAY,KAAA;KAClC,oBAAkB,EAAA,EAAA;2BARV,EAAA,KAAK,CAAA,CAAA;IAYR,EAAA,WAAA,EAAA,GADR,EAOO,QAAA;;KALJ,IAAI,EAAA,EAAA;KACL,OAAM;KACL,aAAW,EAAA,EAAA,IAAI,KAAQ,KAAA;SAErB,EAAA,EAAA,CAAW,GAAA,GAAA,CAAA,KAAA,EAAA,IAAA,EAAA;IAMhB,EAAyB,EAAA,QAAA,WAAA;IAGjB,EAAA,EAAA,KAAA,EAAA,GADR,EAQS,UAAA;;KANP,MAAK;KACL,OAAM;KACL,cAAY,GAAA;KACZ,SAAK,AAAA,EAAA,QAAA,GAAA,MAAE,EAAA,CAAA,KAAA,EAAA,CAAA,CAAA,CAAA,GAAA,CAAA;QAER,EAA2B,GAAA,EAAnB,MAAM,EAAA,CAAA,EAAS,GAAA,MAAA,GAAA,CAAA,MAAA,CAAA,CAAA,GAAA,GAAA,CAAA,KAAA,EAAA,IAAA,EAAA;IAGT,EAAA,WAAA,EAAA,GAAhB,EAAgD,GAAA;;KAAtB,OAAO,EAAA;8BACjC,EAYO,EAAA,QAAA,OAAA,CAAA,SAAA,CAVG,EAAA,WAAW,EAAA,EAAA,KAAA,EAAA,GADnB,EASS,UAAA;;KAPP,MAAK;KACL,OAAM;KACL,cAAY,EAAA,gBAAgB,EAAA,CAAA,CAAQ,CAAC,EAAA,OAAO;KAC5C,UAAU,EAAA,CAAA;KACV,SAAK,AAAA,EAAA,QAAA,MAAE,EAAI,kBAAmB,CAAM;QAErC,EAAqC,GAAA,EAAA,EAAtB,EAAA,CAAA,CAAS,CAAC,EAAA,OAAO,CAAA,CAAA,GAAA,MAAA,EAAA,CAAA,GAAA,GAAA,CAAA,KAEhB,EAAA,WAAA,EAAA,GAAlB,EAAkF,GAAlF,EAAkF,EAAA,KAAA,EAAA,GAA/C,EAAA,CAAA,CAAS,CAAC,EAAA,OAAO,GAAA,EAAG,OAAM,mBAAkB,CAAA,GAAA,MAAA,EAAA,KAAA,EAAA,IAAA,EAAA,CAAA,GAAA,KAAA,GAAA,CAAA;;GAIhE,EAAA,QAAA,EAAA,GAAnB,EAEc,GAAA;;IAFY,IAAI,EAAA,EAAA;IAAQ,SAAQ;IAAU,MAAK;IAAQ,OAAM;;qBAC/D,CAAA,EAAA,EAAP,EAAA,IAAI,GAAA,CAAA,CAAA,CAAA"}
@@ -1,2 +1,2 @@
1
1
  @layer vectis.reset, vectis.tokens, vectis.components, vectis.utilities;
2
- @layer vectis.components{.v-pagination{display:flex}.v-pagination[data-responsive]{container:v-pagination/inline-size}.v-pagination[data-align=center]{justify-content:center}.v-pagination[data-align=end]{justify-content:flex-end}.v-pagination-page[data-size]{min-inline-size:var(--control-height);padding-inline:var(--vectis-space-2);transition:none}.v-pagination-ellipsis[data-size]{cursor:default}@container v-pagination (inline-size<=36rem){.v-pagination[data-responsive] .v-pagination-page[data-distance="3"]{display:none}}@container v-pagination (inline-size<=31rem){.v-pagination[data-responsive] .v-pagination-page[data-distance="2"],.v-pagination[data-responsive][data-controls=both] .v-pagination-control-label{display:none}}@container v-pagination (inline-size<=25rem){.v-pagination[data-responsive] .v-pagination-page[data-distance="1"]{display:none}}.v-pagination[data-item-variant=outline]{--pagination-frame:var(--vectis-color-border-strong)}.v-pagination[data-item-variant=outline]>*>.v-pagination-page:is(:disabled,[aria-disabled=true]){--pagination-frame:var(--vectis-color-border)}.v-pagination[data-item-variant=outline]>*>.v-pagination-page[aria-current=page]:is([data-variant=soft],[data-variant=ghost]){border-color:var(--pagination-frame)}@media (forced-colors:active){.v-pagination-page.v-pagination-page.v-pagination-page.v-pagination-page[aria-current=page]:not(:disabled){forced-color-adjust:none;color:highlighttext;background-color:highlight;border-color:highlight;outline-color:canvastext}}}
2
+ @layer vectis.components{.v-pagination{display:flex}.v-pagination[data-responsive]{container:v-pagination/inline-size}.v-pagination[data-align=center]{justify-content:center}.v-pagination[data-align=end]{justify-content:flex-end}.v-pagination-page[data-size]{min-inline-size:var(--control-height);padding-inline:var(--vectis-space-2);transition:none}.v-pagination-ellipsis[data-size]{cursor:default}@container v-pagination (inline-size<=36rem){.v-pagination[data-responsive] .v-pagination-page[data-distance="3"]{display:none}}@container v-pagination (inline-size<=31rem){.v-pagination[data-responsive] .v-pagination-page[data-distance="2"],.v-pagination[data-responsive][data-controls=both] .v-pagination-control-label{display:none}}@container v-pagination (inline-size<=25rem){.v-pagination[data-responsive] .v-pagination-page[data-distance="1"]{display:none}}.v-pagination[data-item-variant=outline]{--pagination-frame:var(--vectis-color-border-strong)}.v-pagination[data-item-variant=outline]>*>.v-pagination-page:is(:disabled,[aria-disabled=true]){--pagination-frame:var(--vectis-color-border)}.v-pagination[data-item-variant=outline]>*>.v-pagination-page[aria-current=page]:is([data-variant=soft],[data-variant=ghost]){border-color:var(--pagination-frame)}@media (forced-colors:active){.v-pagination-page.v-pagination-page.v-pagination-page.v-pagination-page[aria-current=page]:not(:disabled,[aria-disabled=true]){forced-color-adjust:none;color:highlighttext;background-color:highlight;border-color:highlight;outline-color:canvastext}}}
@@ -111,6 +111,12 @@ interface PaginationProps {
111
111
  * system dictionary.
112
112
  */
113
113
  pageLabel?: (page: number) => string;
114
+ /**
115
+ * The address of a page. Given, every pill and both controls render as links, the
116
+ * previous and next ones carrying `rel="prev"` and `rel="next"`; a click still updates
117
+ * the model, then the browser follows the link. Left out, they are buttons.
118
+ */
119
+ href?: (page: number) => string;
114
120
  }
115
121
  type __VLS_Props = PaginationProps;
116
122
  type __VLS_ModelProps = {
@@ -124,8 +130,10 @@ declare const __VLS_export: import('vue').DefineComponent<__VLS_PublicProps, {
124
130
  /** The `<nav>` element, which is also where the consumer's attributes land. */
125
131
  el: import('vue').Ref<HTMLElement | null, HTMLElement | null>;
126
132
  }, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
133
+ navigate: (page: number, event: MouseEvent) => any;
127
134
  "update:modelValue": (value: number) => any;
128
135
  }, string, import('vue').PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
136
+ onNavigate?: ((page: number, event: MouseEvent) => any) | undefined;
129
137
  "onUpdate:modelValue"?: ((value: number) => any) | undefined;
130
138
  }>, {
131
139
  label: string;
@@ -134,6 +142,7 @@ declare const __VLS_export: import('vue').DefineComponent<__VLS_PublicProps, {
134
142
  tone: PaginationTone;
135
143
  elevated: boolean;
136
144
  compact: boolean;
145
+ href: (page: number) => string;
137
146
  disabled: boolean;
138
147
  detached: boolean;
139
148
  seamless: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"VPagination.js","names":[],"sources":["../../../src/components/VPagination/VPagination.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @keyboard @core\n/**\n * The row of page numbers under a long list. Every pill is a VButton and the previous\n * and next controls are buttons too, so nothing about hovering, focusing or disabling\n * is written again here.\n *\n * A long list has more pages than fit on a line, and the row deals with that TWICE,\n * for two different reasons. First logically: `totalVisible` fixes how many slots are\n * rendered at all, and the pages that do not fit are replaced by an ellipsis — pure\n * arithmetic, identical on the server and in the browser. Then visually: as the space\n * actually available narrows, the neighbours of the current page are hidden one step\n * at a time, and that half is entirely CSS, the row asking about its own width rather\n * than being measured from code.\n *\n * The only behavioural JavaScript is the keyboard, explained where it is written.\n */\nimport { computed, h, nextTick, ref } from 'vue'\nimport type { FunctionalComponent } from 'vue'\n\nimport VButton from '../VButton/VButton.vue'\nimport VButtonGroup from '../VButton/VButtonGroup.vue'\nimport VIcon from '../VIcon/VIcon.vue'\nimport { iconProps } from '../VIcon/iconProps'\nimport { chevron_left as chevronLeftIcon } from '../VIcon/icons/chevron_left'\nimport { chevron_right as chevronRightIcon } from '../VIcon/icons/chevron_right'\nimport { more_horiz as moreHorizIcon } from '../VIcon/icons/more_horiz'\nimport type { IconSource } from '../VIcon/types'\nimport VIconButton from '../VIconButton/VIconButton.vue'\n\nimport { arrowNavigate, navigableItems } from '../../utils/arrowNav'\nimport { clamp } from '../../utils/number'\nimport { resolveMatcher } from '../../utils/matcher'\n\nimport { useAriaLabel } from '../../composables/useAriaLabel'\nimport { useMessages } from '../../i18n/state'\n\n/** How the pages that are not the current one are drawn. */\nexport type PaginationItemVariant = 'ghost' | 'outline'\n\n/** How the current page is drawn. */\nexport type PaginationSelectedVariant = 'solid' | 'soft' | 'ghost'\n\n/** The colour of the current page. */\nexport type PaginationTone = 'accent' | 'neutral' | 'danger'\n\n/** The height of the pills, from the scale every control shares. */\nexport type PaginationSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'\n\n/** Where the row sits in the width it is given. */\nexport type PaginationAlign = 'start' | 'center' | 'end'\n\n/** Whether the previous and next controls are rendered, and what they show. */\nexport type PaginationControls = false | 'icon' | 'text' | 'both'\n\n/**\n * Which pages cannot be reached, given either as a list of page numbers or as a function\n * answering that question for a page.\n */\nexport type PaginationMatcher = number[] | ((page: number) => boolean)\n\ninterface PaginationProps {\n /**\n * How many pages there are in all. It is 1 by default, which renders a single page: the\n * real count almost always has to be given.\n */\n length?: number\n /**\n * How many slots to render, ellipses counted among them, so the row keeps exactly\n * the same width whichever page is current. Below five there would be nothing left\n * to show around the current page, so five is the effective minimum. Left out, every\n * page is rendered.\n */\n totalVisible?: number\n\n /**\n * Separates the buttons instead of joining them into one segmented control. It is the\n * word VButtonGroup and VToggle use for the same question, in the same direction.\n */\n detached?: boolean\n /**\n * Takes the lines out from between the joined buttons, so the row reads as one frame rather\n * than as segments, on the terms of VButtonGroup's own `seamless`. It has no effect under\n * `detached`.\n */\n seamless?: boolean\n /**\n * How the pages OTHER than the current one, and the controls, are drawn. What the current\n * page takes is `selectedVariant`. It is named for the ITEMS because that is what it\n * paints: on VTabs and VDataTable `variant` names the decoration of the frame instead.\n */\n itemVariant?: PaginationItemVariant\n /**\n * How the current page is drawn, in the row's tone: filled, tinted, or the colour of its\n * text alone. The same choice as VToggle's `selectedVariant`.\n */\n selectedVariant?: PaginationSelectedVariant\n /** The colour the current page takes. The other pages and the controls stay neutral. */\n tone?: PaginationTone\n /** The height of the buttons, from the scale shared by every control. */\n size?: PaginationSize\n /** Takes 4px off the height of every button. */\n compact?: boolean\n /**\n * Raises the row off the page, on the terms of VButton's own `elevated`. Joined, the\n * shadow belongs to the ROW and not to each pill, which is what stops it falling into\n * the joints; detached, every button carries its own.\n */\n elevated?: boolean\n /**\n * Where the row sits in the space it is given. It only matters in responsive mode,\n * where the row takes the whole width available.\n */\n align?: PaginationAlign\n\n /**\n * The previous and next buttons on either side of the pages: what they show, or `false`\n * to leave them out. One prop rather than two, the shape VFilePicker's `preview` and\n * VCarousel's `controls` already use.\n */\n controls?: PaginationControls\n /** The icon of the previous control: an icon name, or an explicit render. */\n prevIcon?: IconSource\n /** The icon of the next control: an icon name, or an explicit render. */\n nextIcon?: IconSource\n /**\n * The wording of the previous control, used both as its visible text and as what\n * screen readers announce. It falls back to the design system dictionary.\n */\n prevText?: string\n /**\n * The wording of the next control, used both as its visible text and as what screen\n * readers announce. It falls back to the design system dictionary.\n */\n nextText?: string\n\n /** Makes the whole component unusable. */\n disabled?: boolean\n /**\n * Which pages cannot be reached, as a list or as a function. The previous and next\n * controls step OVER them rather than stopping at one.\n */\n disabledPages?: PaginationMatcher\n\n /**\n * Lets the row shed pages as the space narrows, by asking about its own width. It is\n * off by default, because it makes the row take the full width available.\n */\n responsive?: boolean\n\n /**\n * What screen readers announce for the navigation itself. It falls back to the\n * design system dictionary.\n */\n label?: string\n /**\n * How a page is announced. A pill shows a bare number, which alone means nothing to\n * a screen reader: this is what turns it into \"Page 3\". It falls back to the design\n * system dictionary.\n */\n pageLabel?: (page: number) => string\n}\n\nconst props = withDefaults(defineProps<PaginationProps>(), {\n length: 1,\n totalVisible: undefined,\n detached: false,\n seamless: false,\n itemVariant: 'ghost',\n selectedVariant: 'solid',\n tone: 'accent',\n size: 'md',\n compact: false,\n elevated: false,\n align: 'start',\n controls: 'icon',\n prevIcon: () => chevronLeftIcon,\n nextIcon: () => chevronRightIcon,\n prevText: undefined,\n nextText: undefined,\n disabled: false,\n disabledPages: undefined,\n responsive: false,\n label: undefined,\n pageLabel: undefined,\n})\n\n// The prop wins over the dictionary, and above both, a consumer's own aria-label or\n// aria-labelledby still wins — that arbitration is what `useAriaLabel` is for.\nconst m = useMessages()\nconst ariaLabel = useAriaLabel(() => props.label ?? m.value.pagination.label)\nconst resolvedPrevText = computed(() => props.prevText ?? m.value.pagination.previous)\nconst resolvedNextText = computed(() => props.nextText ?? m.value.pagination.next)\n\n/** The page being shown, counted from 1. It starts on the first. */\nconst page = defineModel<number>({ default: 1 })\n\n/** One slot in the row: either a page, or the ellipsis standing for those left out. */\ntype PaginationItem =\n | { kind: 'page'; key: string; page: number; edge: boolean; distance: number }\n | { kind: 'gap'; key: string }\n\n// A length or a model that is not a whole number (`NaN` while a page size is still loading,\n// a computed `2.5`) is brought back to a page that exists, or no pill would ever match it.\nconst total = computed(() => Math.max(Math.trunc(props.length) || 1, 1))\nconst currentPage = computed(() => clamp(Math.round(page.value) || 1, 1, total.value))\n\nconst isPageDisabled = computed(() => resolveMatcher(props.disabledPages))\n\n// @a11y\n// A pill shows nothing but a number, which on its own tells a screen reader\n// nothing at all; this is what has it announced as \"Page 3\".\nfunction pageLabelFor(n: number): string {\n return props.pageLabel ? props.pageLabel(n) : m.value.pagination.page(n)\n}\n\n/**\n * Works out which slots the row holds.\n *\n * The count is CONSTANT: `totalVisible` counts everything rendered, the ellipses\n * included, so the row never changes width as one moves through the pages. The window\n * of pages around the current one is centred on it, and near either end it SHIFTS\n * rather than shrinking — which is what keeps that count constant there too.\n *\n * Each page also carries how far it is from the current one. That distance is what the\n * responsive hiding sorts by, and it is capped at three: past that, the most distant\n * neighbours all disappear together at the first step.\n */\nconst items = computed<PaginationItem[]>(() => {\n const count = total.value\n const current = currentPage.value\n\n const pageItem = (n: number): PaginationItem => ({\n kind: 'page',\n key: `page-${n}`,\n page: n,\n edge: n === 1 || n === count,\n distance: Math.min(Math.abs(n - current), 3),\n })\n const pages = (from: number, to: number): PaginationItem[] => {\n const out: PaginationItem[] = []\n for (let n = from; n <= to; n++) out.push(pageItem(n))\n return out\n }\n // Keyed by the side it stands on, never by the page it follows: there is at most one\n // ellipsis per side, and the left one always comes after the first page while the right\n // one always comes before the last. A key derived from the window would change on every\n // navigation, so the right-hand ellipsis would be destroyed and rebuilt each time the\n // window slid, for a node that never changes.\n const gap = (side: 'start' | 'end'): PaginationItem => ({ kind: 'gap', key: `gap-${side}` })\n\n // With no limit given, nothing is left out. Below five slots there would be no room\n // for the first page, an ellipsis, the current page, another ellipsis and the last,\n // so that is the floor.\n const limit = Math.trunc(props.totalVisible ?? count)\n const visible = Number.isNaN(limit) ? count : Math.max(limit, 5)\n if (visible >= count) return pages(1, count)\n\n const start = current - Math.floor((visible - 5) / 2)\n const end = start + (visible - 5)\n\n // Close to either end, an ellipsis on that side would stand for no missing page at\n // all. The window is stretched to the bound instead, which both avoids that and\n // keeps the number of slots exactly the same.\n if (start <= 3) return [...pages(1, visible - 2), gap('end'), pageItem(count)]\n if (end >= count - 2) return [pageItem(1), gap('start'), ...pages(count - visible + 3, count)]\n return [pageItem(1), gap('start'), ...pages(start, end), gap('end'), pageItem(count)]\n})\n\n/**\n * Where a control leads: the nearest page in that direction that can actually be\n * reached, disabled ones being stepped over rather than stopped at.\n *\n * Answering `undefined` means there is none left, which disables the control — and\n * that single answer also covers being on the first or the last page, with no separate\n * test for the ends.\n */\nfunction step(direction: -1 | 1): number | undefined {\n for (let n = currentPage.value + direction; n >= 1 && n <= total.value; n += direction) {\n if (!isPageDisabled.value(n)) return n\n }\n return undefined\n}\n\nconst prevTarget = computed(() => step(-1))\nconst nextTarget = computed(() => step(1))\nconst prevDisabled = computed(() => props.disabled || prevTarget.value === undefined)\nconst nextDisabled = computed(() => props.disabled || nextTarget.value === undefined)\n\nfunction goTo(n: number | undefined) {\n if (n === undefined) return\n page.value = clamp(n, 1, total.value)\n}\n\n// @a11y\n/*\n * The previous and next controls disable themselves once no page is left in their direction,\n * and a focused button that becomes disabled hands the focus to <body>: a keyboard reader\n * pressing \"Next page\" on the second to last page would be sent back to the top of the\n * document by the next Tab. The focus goes to the page just reached instead, which is always\n * rendered, the current page being the one pill the responsive steps never hide.\n *\n * The focus is read BEFORE the change: a pointer click may not focus a button at all (Safari),\n * and a control that did not hold the focus has nothing to hand on.\n */\nasync function goFromControl(event: MouseEvent, n: number | undefined) {\n const control = event.currentTarget as HTMLButtonElement\n const hadFocus = control === document.activeElement\n goTo(n)\n if (!hadFocus) return\n await nextTick()\n if (control.disabled) {\n navEl.value?.querySelector<HTMLElement>('[aria-current=\"page\"]')?.focus()\n }\n}\n\n/*\n * The previous and next controls, written ONCE: the two differ only by their direction, and\n * a pair of template blocks drifting apart on the next change is exactly what this avoids.\n * A functional component because the two sit at either end of the row, with the pages in\n * between, where a `v-for` over two descriptors could not put them.\n *\n * The icon goes on the side the control points to — before the word going back, after it\n * going forward — and every icon mirrors in a right-to-left page, an arrow pointing at a\n * physical direction.\n *\n * The control is named explicitly even though its label is visible: at narrow widths that\n * label is hidden and only the icon remains, and the name a screen reader announces has to\n * survive that.\n */\nconst PageControl: FunctionalComponent<{ side: 'prev' | 'next' }> = ({ side }) => {\n const prev = side === 'prev'\n const label = prev ? resolvedPrevText.value : resolvedNextText.value\n const icon = prev ? props.prevIcon : props.nextIcon\n const common = {\n class: 'v-pagination-control',\n variant: props.itemVariant,\n disabled: prev ? prevDisabled.value : nextDisabled.value,\n onClick: (event: MouseEvent) =>\n goFromControl(event, prev ? prevTarget.value : nextTarget.value),\n }\n const glyph = () => h(VIcon, { ...iconProps(icon), mirrored: true })\n if (props.controls === 'icon') return h(VIconButton, { ...common, label }, glyph)\n return h(\n VButton,\n { ...common, tone: 'neutral', 'aria-label': label },\n {\n ...(props.controls === 'both' ? { [prev ? 'start' : 'end']: glyph } : {}),\n default: () => h('span', { class: 'v-pagination-control-label' }, label),\n },\n )\n}\n\nconst navEl = ref<HTMLElement | null>(null)\n\n// @keyboard @a11y\n/**\n * The arrow keys, through the shared implementation in `utils/arrowNav`. Tab is left\n * alone: every visible pill remains a stop in the tab order, as in any list of links.\n *\n * The pills the responsive rules have hidden are left out by the helper, which skips\n * anything not displayed — so they cannot be focused into.\n */\nfunction onKeydown(event: KeyboardEvent) {\n const nav = navEl.value\n if (!nav) return\n arrowNavigate(event, nav, () => navigableItems(nav, '.v-pagination-page:not(:disabled)'))\n}\n\n// The pills are rendered by a VButtonGroup inside the nav, so a template ref reaches none of\n// them. `focus` goes to the current page, which every responsive step keeps on screen.\ndefineExpose({\n // Two queries, not one selector list: a list matches in DOM order, which would hand the\n // focus to the previous control ahead of the current page.\n /** Moves the focus to the current page, or to the first control that can take it. */\n focus: (options?: FocusOptions) =>\n (\n navEl.value?.querySelector<HTMLElement>('[aria-current=\"page\"]:not(:disabled)') ??\n navEl.value?.querySelector<HTMLElement>('button:not(:disabled)')\n )?.focus(options),\n /** The `<nav>` element, which is also where the consumer's attributes land. */\n el: navEl,\n})\n</script>\n\n<template>\n <nav\n ref=\"navEl\"\n class=\"v-pagination\"\n :aria-label=\"ariaLabel\"\n :data-align=\"align\"\n :data-item-variant=\"itemVariant\"\n :data-controls=\"controls || undefined\"\n :data-responsive=\"responsive ? '' : undefined\"\n @keydown=\"onKeydown\"\n >\n <!-- The row is a VButtonGroup either way: joined it merges the borders of its DIRECT\n button children, and detached it spaces them, which is all this prop asks of it.\n That is why there is no list markup wrapping the pills, and why the ellipsis is\n itself an inert button rather than a plain span: anything else between two pills\n would break the seam. -->\n <!--\n The size, the density and the elevation are handed to the GROUP, which gives them to\n every button inside, rather than repeated on each child. The elevation has to be there\n in any case, since that is where VButtonGroup draws it: joined, the row takes the shadow\n and the segments give theirs up, or it would fall into every joint.\n\n `|| undefined` keeps the group opinion-free when the row does not ask, `undefined`\n being what means \"no opinion\" there where `false` is an order. The size is always an\n opinion, the pagination having a default of its own.\n -->\n <VButtonGroup\n class=\"v-pagination-items\"\n :detached=\"detached\"\n :seamless=\"seamless\"\n :size=\"size\"\n :compact=\"compact || undefined\"\n :elevated=\"elevated || undefined\"\n >\n <PageControl v-if=\"controls\" side=\"prev\" />\n\n <template v-for=\"item in items\" :key=\"item.key\">\n <VButton\n v-if=\"item.kind === 'page'\"\n class=\"v-pagination-page\"\n :variant=\"item.page === currentPage ? selectedVariant : itemVariant\"\n :tone=\"item.page === currentPage ? tone : 'neutral'\"\n :disabled=\"disabled || isPageDisabled(item.page)\"\n :aria-label=\"pageLabelFor(item.page)\"\n :aria-current=\"item.page === currentPage ? 'page' : undefined\"\n :data-edge=\"item.edge ? '' : undefined\"\n :data-distance=\"!item.edge && item.distance > 0 ? item.distance : undefined\"\n @click=\"goTo(item.page)\"\n >\n {{ item.page }}\n </VButton>\n <!-- The ellipsis is a disabled icon-only button rather than a span: being a button,\n it keeps the joined row's seam continuous and follows the size and density like\n everything else. Disabling it takes it out of the tab order, and it is hidden\n from screen readers, which have the page numbers themselves — which is also why\n it is a VButton and not a VIconButton, whose required label would name something\n nobody can reach. -->\n <VButton\n v-else\n class=\"v-pagination-ellipsis\"\n aria-hidden=\"true\"\n data-icon-only\n :variant=\"itemVariant\"\n tone=\"neutral\"\n disabled\n >\n <template #start>\n <VIcon :name=\"moreHorizIcon\" />\n </template>\n </VButton>\n </template>\n\n <PageControl v-if=\"controls\" side=\"next\" />\n </VButtonGroup>\n </nav>\n</template>\n\n<style>\n@layer vectis.components {\n .v-pagination {\n display: flex;\n }\n\n /*\n * Making the row a query container is reserved for responsive mode, since every\n * query below depends on it — and it is not free.\n *\n * A container of this kind computes its width WITHOUT looking at its content, so the\n * row has to be block-level: as an inline box it would measure zero and hide\n * everything at once. It therefore takes its parent's whole width, which is exactly\n * what makes the hiding follow the space the component was actually given, and what\n * obliges a flex parent to grant it one.\n *\n * Outside responsive mode the row keeps a width of its own and sits like any other\n * content. Either way the alignment is asked for explicitly and never inferred.\n */\n .v-pagination[data-responsive] {\n container-type: inline-size;\n container-name: v-pagination;\n }\n\n .v-pagination[data-align='center'] {\n justify-content: center;\n }\n\n .v-pagination[data-align='end'] {\n justify-content: flex-end;\n }\n\n /* Qualified by an attribute VButton always renders, which is what makes both of these\n beat that button's own padding and its own transition whatever order the two sheets\n end up in — the VIconButton idiom. */\n .v-pagination-page[data-size] {\n /* A one-digit pill is square, and widens by itself past that. The height variable\n is set by the shared size class on this very element, so this single rule covers\n all five sizes and their compact forms. */\n min-inline-size: var(--control-height);\n padding-inline: var(--vectis-space-2);\n\n /*\n * TRAP — the highlight changes instantly, and this must not be \"restored for\n * consistency\". VButton fades `background-color` over `--vectis-duration-fast`, which is\n * right for a button that stays put and wrong here: truncation SHIFTS its window, so from\n * one page to the next the highlight keeps its slot but changes ELEMENT, and the outgoing\n * and incoming pills are both half-tinted for a few frames — read as a flicker, with\n * nothing in the console.\n *\n * No duration avoids it: symmetric fades overlap, asymmetric ones leave either no\n * highlight or a trailing one. Keeping the fade for hover alone is not expressible\n * either — a transition resolves from the state being ENTERED, and \"no longer current\"\n * is the same state as \"no longer hovered\". So the hover fade goes too, on the pills\n * only; the controls never change active state and keep theirs.\n */\n transition: none;\n }\n\n /* The ellipsis is a disabled button only as a technical device; to the reader it is\n not a control that has been turned off, so it does not take the forbidden\n cursor. Qualified like the pills: VButton's disabled cursor weighs (0,2,0), and a bare\n class loses to it whatever the sheet order. */\n .v-pagination-ellipsis[data-size] {\n cursor: default;\n }\n\n /*\n * The responsive half of the truncation. The nav queries ITS OWN width, so the steps follow\n * the space the component was given rather than the viewport — a sidebar and a full-width\n * page behave differently, as they should, with nothing measured from code.\n *\n * The thresholds are rem LITERALS, a container query accepting no variables, and are\n * calibrated on `md`: a pill and a control are each 2.5rem plus a 0.25rem gutter, so\n * 2.75rem apiece — thirteen slots need ~35.5rem, eleven ~30, nine ~24.5, hence the three\n * with a little margin. The extremes (the largest size, five-digit page numbers) are what\n * `totalVisible` and `responsive: false` are for.\n *\n * Each threshold is the width NEEDED to show that level, never what remains after hiding:\n * written the other way round the row overflows for the whole interval before the next\n * step takes effect.\n *\n * The most distant neighbours go first. The edges and the current page carry no distance,\n * so no rule here can reach them. `display: none` also takes a hidden pill out of the tab\n * order and the a11y tree, which is intended. No ellipsis replaces one either — it is\n * exactly as wide as the pill it would stand for, so it frees nothing.\n */\n @container v-pagination (max-inline-size: 36rem) {\n .v-pagination[data-responsive] .v-pagination-page[data-distance='3'] {\n display: none;\n }\n }\n\n @container v-pagination (max-inline-size: 31rem) {\n .v-pagination[data-responsive] .v-pagination-page[data-distance='2'] {\n display: none;\n }\n\n /* A control showing both its icon and its label is about four times as wide as a\n pill, so dropping its label frees more room than sacrificing another page —\n which is why it happens before the last step. It is never done when the control\n shows text alone: there would be nothing left to click. */\n .v-pagination[data-responsive][data-controls='both'] .v-pagination-control-label {\n display: none;\n }\n }\n\n @container v-pagination (max-inline-size: 25rem) {\n .v-pagination[data-responsive] .v-pagination-page[data-distance='1'] {\n display: none;\n }\n }\n\n /*\n * The frame of an `outline` row: the colour its other pages already paint. They are drawn\n * in the NEUTRAL tone, so this is what their `--tone-border-soft` resolves to; naming that\n * variable here instead would read the CURRENT page's tone and tint one segment of the\n * frame accent or red. A disabled page greys its outline to a different token, hence a\n * variable the second rule changes. VToggle's `--toggle-frame` is the same device.\n *\n * `soft` and `ghost` leave VButton's border transparent, which in an outline row opens a\n * gap in the frame for the whole width of the current page (the `SelectedVariants` play\n * function). Restoring it on all four sides keeps the frame closed wherever the current\n * page sits; seamless, the shared edges are cleared again by VButtonGroup's own (0,6,0)\n * rules, which these (0,5,0) ones stay below.\n */\n .v-pagination[data-item-variant='outline'] {\n --pagination-frame: var(--vectis-color-border-strong);\n }\n\n .v-pagination[data-item-variant='outline']\n > *\n > .v-pagination-page:is(:disabled, [aria-disabled='true']) {\n --pagination-frame: var(--vectis-color-border);\n }\n\n .v-pagination[data-item-variant='outline']\n > *\n > .v-pagination-page[aria-current='page']:is([data-variant='soft'], [data-variant='ghost']) {\n border-color: var(--pagination-frame);\n }\n\n /*\n * Windows forced colors replace every background with the page's, so the current page would look\n * exactly like the others there: the fill is its only cue, the state being in an ARIA\n * attribute a sighted reader never sees. It takes the system's own selection pair\n * instead, and opts out of the forcing for that element alone so the pair is painted.\n *\n * TRAP — the class is repeated to reach (0,6,0). The variant, hover and active rules\n * reach (0,5,0), and with the forcing turned off any of them that still won would paint\n * its tone over the selection, with HighlightText on top of it.\n */\n @media (forced-colors: active) {\n .v-pagination-page.v-pagination-page.v-pagination-page.v-pagination-page[aria-current='page']:not(\n :disabled\n ) {\n forced-color-adjust: none;\n background-color: Highlight;\n color: HighlightText;\n border-color: Highlight;\n outline-color: CanvasText;\n }\n }\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"VPagination.js","names":[],"sources":["../../../src/components/VPagination/VPagination.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @keyboard @core\n/**\n * The row of page numbers under a long list. Every pill is a VButton and the previous\n * and next controls are buttons too, so nothing about hovering, focusing or disabling\n * is written again here.\n *\n * A long list has more pages than fit on a line, and the row deals with that TWICE,\n * for two different reasons. First logically: `totalVisible` fixes how many slots are\n * rendered at all, and the pages that do not fit are replaced by an ellipsis — pure\n * arithmetic, identical on the server and in the browser. Then visually: as the space\n * actually available narrows, the neighbours of the current page are hidden one step\n * at a time, and that half is entirely CSS, the row asking about its own width rather\n * than being measured from code.\n *\n * Given `href`, every pill and both controls are links, so a list of products can be\n * paged by address: middle-clicked into a new tab, bookmarked, followed by a crawler.\n *\n * The only behavioural JavaScript is the keyboard, explained where it is written.\n */\nimport { computed, h, nextTick, ref } from 'vue'\nimport type { FunctionalComponent } from 'vue'\n\nimport VButton from '../VButton/VButton.vue'\nimport VButtonGroup from '../VButton/VButtonGroup.vue'\nimport VIcon from '../VIcon/VIcon.vue'\nimport { iconProps } from '../VIcon/iconProps'\nimport { chevron_left as chevronLeftIcon } from '../VIcon/icons/chevron_left'\nimport { chevron_right as chevronRightIcon } from '../VIcon/icons/chevron_right'\nimport { more_horiz as moreHorizIcon } from '../VIcon/icons/more_horiz'\nimport type { IconSource } from '../VIcon/types'\nimport VIconButton from '../VIconButton/VIconButton.vue'\n\nimport { arrowNavigate, navigableItems } from '../../utils/arrowNav'\nimport { clamp } from '../../utils/number'\nimport { resolveMatcher } from '../../utils/matcher'\n\nimport { useAriaLabel } from '../../composables/useAriaLabel'\nimport { useMessages } from '../../i18n/state'\n\n/** How the pages that are not the current one are drawn. */\nexport type PaginationItemVariant = 'ghost' | 'outline'\n\n/** How the current page is drawn. */\nexport type PaginationSelectedVariant = 'solid' | 'soft' | 'ghost'\n\n/** The colour of the current page. */\nexport type PaginationTone = 'accent' | 'neutral' | 'danger'\n\n/** The height of the pills, from the scale every control shares. */\nexport type PaginationSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'\n\n/** Where the row sits in the width it is given. */\nexport type PaginationAlign = 'start' | 'center' | 'end'\n\n/** Whether the previous and next controls are rendered, and what they show. */\nexport type PaginationControls = false | 'icon' | 'text' | 'both'\n\n/**\n * Which pages cannot be reached, given either as a list of page numbers or as a function\n * answering that question for a page.\n */\nexport type PaginationMatcher = number[] | ((page: number) => boolean)\n\ninterface PaginationProps {\n /**\n * How many pages there are in all. It is 1 by default, which renders a single page: the\n * real count almost always has to be given.\n */\n length?: number\n /**\n * How many slots to render, ellipses counted among them, so the row keeps exactly\n * the same width whichever page is current. Below five there would be nothing left\n * to show around the current page, so five is the effective minimum. Left out, every\n * page is rendered.\n */\n totalVisible?: number\n\n /**\n * Separates the buttons instead of joining them into one segmented control. It is the\n * word VButtonGroup and VToggle use for the same question, in the same direction.\n */\n detached?: boolean\n /**\n * Takes the lines out from between the joined buttons, so the row reads as one frame rather\n * than as segments, on the terms of VButtonGroup's own `seamless`. It has no effect under\n * `detached`.\n */\n seamless?: boolean\n /**\n * How the pages OTHER than the current one, and the controls, are drawn. What the current\n * page takes is `selectedVariant`. It is named for the ITEMS because that is what it\n * paints: on VTabs and VDataTable `variant` names the decoration of the frame instead.\n */\n itemVariant?: PaginationItemVariant\n /**\n * How the current page is drawn, in the row's tone: filled, tinted, or the colour of its\n * text alone. The same choice as VToggle's `selectedVariant`.\n */\n selectedVariant?: PaginationSelectedVariant\n /** The colour the current page takes. The other pages and the controls stay neutral. */\n tone?: PaginationTone\n /** The height of the buttons, from the scale shared by every control. */\n size?: PaginationSize\n /** Takes 4px off the height of every button. */\n compact?: boolean\n /**\n * Raises the row off the page, on the terms of VButton's own `elevated`. Joined, the\n * shadow belongs to the ROW and not to each pill, which is what stops it falling into\n * the joints; detached, every button carries its own.\n */\n elevated?: boolean\n /**\n * Where the row sits in the space it is given. It only matters in responsive mode,\n * where the row takes the whole width available.\n */\n align?: PaginationAlign\n\n /**\n * The previous and next buttons on either side of the pages: what they show, or `false`\n * to leave them out. One prop rather than two, the shape VFilePicker's `preview` and\n * VCarousel's `controls` already use.\n */\n controls?: PaginationControls\n /** The icon of the previous control: an icon name, or an explicit render. */\n prevIcon?: IconSource\n /** The icon of the next control: an icon name, or an explicit render. */\n nextIcon?: IconSource\n /**\n * The wording of the previous control, used both as its visible text and as what\n * screen readers announce. It falls back to the design system dictionary.\n */\n prevText?: string\n /**\n * The wording of the next control, used both as its visible text and as what screen\n * readers announce. It falls back to the design system dictionary.\n */\n nextText?: string\n\n /** Makes the whole component unusable. */\n disabled?: boolean\n /**\n * Which pages cannot be reached, as a list or as a function. The previous and next\n * controls step OVER them rather than stopping at one.\n */\n disabledPages?: PaginationMatcher\n\n /**\n * Lets the row shed pages as the space narrows, by asking about its own width. It is\n * off by default, because it makes the row take the full width available.\n */\n responsive?: boolean\n\n /**\n * What screen readers announce for the navigation itself. It falls back to the\n * design system dictionary.\n */\n label?: string\n /**\n * How a page is announced. A pill shows a bare number, which alone means nothing to\n * a screen reader: this is what turns it into \"Page 3\". It falls back to the design\n * system dictionary.\n */\n pageLabel?: (page: number) => string\n /**\n * The address of a page. Given, every pill and both controls render as links, the\n * previous and next ones carrying `rel=\"prev\"` and `rel=\"next\"`; a click still updates\n * the model, then the browser follows the link. Left out, they are buttons.\n */\n href?: (page: number) => string\n}\n\nconst props = withDefaults(defineProps<PaginationProps>(), {\n length: 1,\n totalVisible: undefined,\n detached: false,\n seamless: false,\n itemVariant: 'ghost',\n selectedVariant: 'solid',\n tone: 'accent',\n size: 'md',\n compact: false,\n elevated: false,\n align: 'start',\n controls: 'icon',\n prevIcon: () => chevronLeftIcon,\n nextIcon: () => chevronRightIcon,\n prevText: undefined,\n nextText: undefined,\n disabled: false,\n disabledPages: undefined,\n responsive: false,\n label: undefined,\n pageLabel: undefined,\n href: undefined,\n})\n\nconst emit = defineEmits<{\n /**\n * A page was chosen from the row, a pill or a control, with the click that chose it. It\n * comes before the model changes, so under `href` a single-page application calls\n * `preventDefault()` on the event and hands the address to its router instead of letting\n * the browser load it. A click that opens the link elsewhere (a modifier key held) is not\n * a choice made here and emits nothing.\n */\n navigate: [page: number, event: MouseEvent]\n}>()\n\n// The prop wins over the dictionary, and above both, a consumer's own aria-label or\n// aria-labelledby still wins — that arbitration is what `useAriaLabel` is for.\nconst m = useMessages()\nconst ariaLabel = useAriaLabel(() => props.label ?? m.value.pagination.label)\nconst resolvedPrevText = computed(() => props.prevText ?? m.value.pagination.previous)\nconst resolvedNextText = computed(() => props.nextText ?? m.value.pagination.next)\n\n/** The page being shown, counted from 1. It starts on the first. */\nconst page = defineModel<number>({ default: 1 })\n\n/** One slot in the row: either a page, or the ellipsis standing for those left out. */\ntype PaginationItem =\n | { kind: 'page'; key: string; page: number; edge: boolean; distance: number }\n | { kind: 'gap'; key: string }\n\n// A length or a model that is not a whole number (`NaN` while a page size is still loading,\n// a computed `2.5`) is brought back to a page that exists, or no pill would ever match it.\nconst total = computed(() => Math.max(Math.trunc(props.length) || 1, 1))\nconst currentPage = computed(() => clamp(Math.round(page.value) || 1, 1, total.value))\n\nconst isPageDisabled = computed(() => resolveMatcher(props.disabledPages))\n\n// @a11y\n// A pill shows nothing but a number, which on its own tells a screen reader\n// nothing at all; this is what has it announced as \"Page 3\".\nfunction pageLabelFor(n: number): string {\n return props.pageLabel ? props.pageLabel(n) : m.value.pagination.page(n)\n}\n\n/**\n * Works out which slots the row holds.\n *\n * The count is CONSTANT: `totalVisible` counts everything rendered, the ellipses\n * included, so the row never changes width as one moves through the pages. The window\n * of pages around the current one is centred on it, and near either end it SHIFTS\n * rather than shrinking — which is what keeps that count constant there too.\n *\n * Each page also carries how far it is from the current one. That distance is what the\n * responsive hiding sorts by, and it is capped at three: past that, the most distant\n * neighbours all disappear together at the first step.\n */\nconst items = computed<PaginationItem[]>(() => {\n const count = total.value\n const current = currentPage.value\n\n const pageItem = (n: number): PaginationItem => ({\n kind: 'page',\n key: `page-${n}`,\n page: n,\n edge: n === 1 || n === count,\n distance: Math.min(Math.abs(n - current), 3),\n })\n const pages = (from: number, to: number): PaginationItem[] => {\n const out: PaginationItem[] = []\n for (let n = from; n <= to; n++) out.push(pageItem(n))\n return out\n }\n // Keyed by the side it stands on, never by the page it follows: there is at most one\n // ellipsis per side, and the left one always comes after the first page while the right\n // one always comes before the last. A key derived from the window would change on every\n // navigation, so the right-hand ellipsis would be destroyed and rebuilt each time the\n // window slid, for a node that never changes.\n const gap = (side: 'start' | 'end'): PaginationItem => ({ kind: 'gap', key: `gap-${side}` })\n\n // With no limit given, nothing is left out. Below five slots there would be no room\n // for the first page, an ellipsis, the current page, another ellipsis and the last,\n // so that is the floor.\n const limit = Math.trunc(props.totalVisible ?? count)\n const visible = Number.isNaN(limit) ? count : Math.max(limit, 5)\n if (visible >= count) return pages(1, count)\n\n const start = current - Math.floor((visible - 5) / 2)\n const end = start + (visible - 5)\n\n // Close to either end, an ellipsis on that side would stand for no missing page at\n // all. The window is stretched to the bound instead, which both avoids that and\n // keeps the number of slots exactly the same.\n if (start <= 3) return [...pages(1, visible - 2), gap('end'), pageItem(count)]\n if (end >= count - 2) return [pageItem(1), gap('start'), ...pages(count - visible + 3, count)]\n return [pageItem(1), gap('start'), ...pages(start, end), gap('end'), pageItem(count)]\n})\n\n/**\n * Where a control leads: the nearest page in that direction that can actually be\n * reached, disabled ones being stepped over rather than stopped at.\n *\n * Answering `undefined` means there is none left, which disables the control — and\n * that single answer also covers being on the first or the last page, with no separate\n * test for the ends.\n */\nfunction step(direction: -1 | 1): number | undefined {\n for (let n = currentPage.value + direction; n >= 1 && n <= total.value; n += direction) {\n if (!isPageDisabled.value(n)) return n\n }\n return undefined\n}\n\nconst prevTarget = computed(() => step(-1))\nconst nextTarget = computed(() => step(1))\nconst prevDisabled = computed(() => props.disabled || prevTarget.value === undefined)\nconst nextDisabled = computed(() => props.disabled || nextTarget.value === undefined)\n\nfunction goTo(n: number | undefined) {\n if (n === undefined) return\n page.value = clamp(n, 1, total.value)\n}\n\n/** The address a pill or a control links to, or `undefined` when the row is made of buttons. */\nfunction hrefFor(n: number): string | undefined {\n return props.href?.(n)\n}\n\n// @core\n/*\n * A link clicked with a modifier held (Ctrl or Cmd for a new tab, Shift for a window, Alt for a\n * download) opens somewhere ELSE: the page on screen stays where it is, so the model must not\n * move. The test the routers themselves make before taking a click over.\n */\nfunction opensElsewhere(event: MouseEvent): boolean {\n return (\n props.href !== undefined &&\n (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)\n )\n}\n\nfunction choose(event: MouseEvent, n: number | undefined) {\n if (n === undefined || opensElsewhere(event)) return\n emit('navigate', n, event)\n goTo(n)\n}\n\n/** Whether a pill or a control can no longer be used: a disabled button, or an inert link. */\nfunction isUnusable(el: HTMLElement): boolean {\n return el.matches(':disabled, [aria-disabled=\"true\"]')\n}\n\n// @a11y\n/*\n * The previous and next controls disable themselves once no page is left in their direction,\n * and a focused button that becomes disabled hands the focus to <body>: a keyboard reader\n * pressing \"Next page\" on the second to last page would be sent back to the top of the\n * document by the next Tab. The focus goes to the page just reached instead, which is always\n * rendered, the current page being the one pill the responsive steps never hide.\n *\n * The focus is read BEFORE the change: a pointer click may not focus a button at all (Safari),\n * and a control that did not hold the focus has nothing to hand on.\n */\nasync function goFromControl(event: MouseEvent, n: number | undefined) {\n const control = event.currentTarget as HTMLElement\n const hadFocus = control === document.activeElement\n choose(event, n)\n if (!hadFocus) return\n await nextTick()\n if (isUnusable(control)) {\n navEl.value?.querySelector<HTMLElement>('[aria-current=\"page\"]')?.focus()\n }\n}\n\n/*\n * The previous and next controls, written ONCE: the two differ only by their direction, and\n * a pair of template blocks drifting apart on the next change is exactly what this avoids.\n * A functional component because the two sit at either end of the row, with the pages in\n * between, where a `v-for` over two descriptors could not put them.\n *\n * The icon goes on the side the control points to — before the word going back, after it\n * going forward — and every icon mirrors in a right-to-left page, an arrow pointing at a\n * physical direction.\n *\n * The control is named explicitly even though its label is visible: at narrow widths that\n * label is hidden and only the icon remains, and the name a screen reader announces has to\n * survive that.\n *\n * As a link it points at the page it leads to. With none left it still receives an address\n * (the current page's), which VButton drops once disabled: the control stays the same `<a>`,\n * turned inert, rather than swapping to a `<button>` under a focus `goFromControl` has to read.\n * The `rel` goes with the address, an inert link leading nowhere.\n */\nconst PageControl: FunctionalComponent<{ side: 'prev' | 'next' }> = ({ side }) => {\n const prev = side === 'prev'\n const label = prev ? resolvedPrevText.value : resolvedNextText.value\n const icon = prev ? props.prevIcon : props.nextIcon\n const target = prev ? prevTarget.value : nextTarget.value\n const disabled = prev ? prevDisabled.value : nextDisabled.value\n const common = {\n class: 'v-pagination-control',\n variant: props.itemVariant,\n disabled,\n href: hrefFor(target ?? currentPage.value),\n rel: props.href && !disabled ? side : undefined,\n onClick: (event: MouseEvent) => goFromControl(event, target),\n }\n const glyph = () => h(VIcon, { ...iconProps(icon), mirrored: true })\n if (props.controls === 'icon') return h(VIconButton, { ...common, label }, glyph)\n return h(\n VButton,\n { ...common, tone: 'neutral', 'aria-label': label },\n {\n ...(props.controls === 'both' ? { [prev ? 'start' : 'end']: glyph } : {}),\n default: () => h('span', { class: 'v-pagination-control-label' }, label),\n },\n )\n}\n\nconst navEl = ref<HTMLElement | null>(null)\n\n// @keyboard @a11y\n/**\n * The arrow keys, through the shared implementation in `utils/arrowNav`. Tab is left\n * alone: every visible pill remains a stop in the tab order, as in any list of links.\n *\n * The pills the responsive rules have hidden are left out by the helper, which skips\n * anything not displayed — so they cannot be focused into.\n */\nfunction onKeydown(event: KeyboardEvent) {\n const nav = navEl.value\n if (!nav) return\n arrowNavigate(event, nav, () =>\n navigableItems(nav, '.v-pagination-page:not(:disabled, [aria-disabled=\"true\"])'),\n )\n}\n\n// The pills are rendered by a VButtonGroup inside the nav, so a template ref reaches none of\n// them. `focus` goes to the current page, which every responsive step keeps on screen.\ndefineExpose({\n // Two queries, not one selector list: a list matches in DOM order, which would hand the\n // focus to the previous control ahead of the current page. `a[href]` because an inert link\n // has lost its address, and with it the ability to take the focus.\n /** Moves the focus to the current page, or to the first control that can take it. */\n focus: (options?: FocusOptions) =>\n (\n navEl.value?.querySelector<HTMLElement>(\n '[aria-current=\"page\"]:is(button, a[href]):not(:disabled)',\n ) ?? navEl.value?.querySelector<HTMLElement>(':is(button, a[href]):not(:disabled)')\n )?.focus(options),\n /** The `<nav>` element, which is also where the consumer's attributes land. */\n el: navEl,\n})\n</script>\n\n<template>\n <nav\n ref=\"navEl\"\n class=\"v-pagination\"\n :aria-label=\"ariaLabel\"\n :data-align=\"align\"\n :data-item-variant=\"itemVariant\"\n :data-controls=\"controls || undefined\"\n :data-responsive=\"responsive ? '' : undefined\"\n @keydown=\"onKeydown\"\n >\n <!-- The row is a VButtonGroup either way: joined it merges the borders of its DIRECT\n button children, and detached it spaces them, which is all this prop asks of it.\n That is why there is no list markup wrapping the pills, and why the ellipsis is\n itself an inert button rather than a plain span: anything else between two pills\n would break the seam. -->\n <!--\n The size, the density and the elevation are handed to the GROUP, which gives them to\n every button inside, rather than repeated on each child. The elevation has to be there\n in any case, since that is where VButtonGroup draws it: joined, the row takes the shadow\n and the segments give theirs up, or it would fall into every joint.\n\n `|| undefined` keeps the group opinion-free when the row does not ask, `undefined`\n being what means \"no opinion\" there where `false` is an order. The size is always an\n opinion, the pagination having a default of its own.\n -->\n <VButtonGroup\n class=\"v-pagination-items\"\n :detached=\"detached\"\n :seamless=\"seamless\"\n :size=\"size\"\n :compact=\"compact || undefined\"\n :elevated=\"elevated || undefined\"\n >\n <PageControl v-if=\"controls\" side=\"prev\" />\n\n <template v-for=\"item in items\" :key=\"item.key\">\n <VButton\n v-if=\"item.kind === 'page'\"\n class=\"v-pagination-page\"\n :variant=\"item.page === currentPage ? selectedVariant : itemVariant\"\n :tone=\"item.page === currentPage ? tone : 'neutral'\"\n :disabled=\"disabled || isPageDisabled(item.page)\"\n :aria-label=\"pageLabelFor(item.page)\"\n :aria-current=\"item.page === currentPage ? 'page' : undefined\"\n :href=\"hrefFor(item.page)\"\n :data-edge=\"item.edge ? '' : undefined\"\n :data-distance=\"!item.edge && item.distance > 0 ? item.distance : undefined\"\n @click=\"choose($event, item.page)\"\n >\n {{ item.page }}\n </VButton>\n <!-- The ellipsis is a disabled icon-only button rather than a span: being a button,\n it keeps the joined row's seam continuous and follows the size and density like\n everything else. Disabling it takes it out of the tab order, and it is hidden\n from screen readers, which have the page numbers themselves — which is also why\n it is a VButton and not a VIconButton, whose required label would name something\n nobody can reach. -->\n <VButton\n v-else\n class=\"v-pagination-ellipsis\"\n aria-hidden=\"true\"\n data-icon-only\n :variant=\"itemVariant\"\n tone=\"neutral\"\n disabled\n >\n <template #start>\n <VIcon :name=\"moreHorizIcon\" />\n </template>\n </VButton>\n </template>\n\n <PageControl v-if=\"controls\" side=\"next\" />\n </VButtonGroup>\n </nav>\n</template>\n\n<style>\n@layer vectis.components {\n .v-pagination {\n display: flex;\n }\n\n /*\n * Making the row a query container is reserved for responsive mode, since every\n * query below depends on it — and it is not free.\n *\n * A container of this kind computes its width WITHOUT looking at its content, so the\n * row has to be block-level: as an inline box it would measure zero and hide\n * everything at once. It therefore takes its parent's whole width, which is exactly\n * what makes the hiding follow the space the component was actually given, and what\n * obliges a flex parent to grant it one.\n *\n * Outside responsive mode the row keeps a width of its own and sits like any other\n * content. Either way the alignment is asked for explicitly and never inferred.\n */\n .v-pagination[data-responsive] {\n container-type: inline-size;\n container-name: v-pagination;\n }\n\n .v-pagination[data-align='center'] {\n justify-content: center;\n }\n\n .v-pagination[data-align='end'] {\n justify-content: flex-end;\n }\n\n /* Qualified by an attribute VButton always renders, which is what makes both of these\n beat that button's own padding and its own transition whatever order the two sheets\n end up in — the VIconButton idiom. */\n .v-pagination-page[data-size] {\n /* A one-digit pill is square, and widens by itself past that. The height variable\n is set by the shared size class on this very element, so this single rule covers\n all five sizes and their compact forms. */\n min-inline-size: var(--control-height);\n padding-inline: var(--vectis-space-2);\n\n /*\n * TRAP — the highlight changes instantly, and this must not be \"restored for\n * consistency\". VButton fades `background-color` over `--vectis-duration-fast`, which is\n * right for a button that stays put and wrong here: truncation SHIFTS its window, so from\n * one page to the next the highlight keeps its slot but changes ELEMENT, and the outgoing\n * and incoming pills are both half-tinted for a few frames — read as a flicker, with\n * nothing in the console.\n *\n * No duration avoids it: symmetric fades overlap, asymmetric ones leave either no\n * highlight or a trailing one. Keeping the fade for hover alone is not expressible\n * either — a transition resolves from the state being ENTERED, and \"no longer current\"\n * is the same state as \"no longer hovered\". So the hover fade goes too, on the pills\n * only; the controls never change active state and keep theirs.\n */\n transition: none;\n }\n\n /* The ellipsis is a disabled button only as a technical device; to the reader it is\n not a control that has been turned off, so it does not take the forbidden\n cursor. Qualified like the pills: VButton's disabled cursor weighs (0,2,0), and a bare\n class loses to it whatever the sheet order. */\n .v-pagination-ellipsis[data-size] {\n cursor: default;\n }\n\n /*\n * The responsive half of the truncation. The nav queries ITS OWN width, so the steps follow\n * the space the component was given rather than the viewport — a sidebar and a full-width\n * page behave differently, as they should, with nothing measured from code.\n *\n * The thresholds are rem LITERALS, a container query accepting no variables, and are\n * calibrated on `md`: a pill and a control are each 2.5rem plus a 0.25rem gutter, so\n * 2.75rem apiece — thirteen slots need ~35.5rem, eleven ~30, nine ~24.5, hence the three\n * with a little margin. The extremes (the largest size, five-digit page numbers) are what\n * `totalVisible` and `responsive: false` are for.\n *\n * Each threshold is the width NEEDED to show that level, never what remains after hiding:\n * written the other way round the row overflows for the whole interval before the next\n * step takes effect.\n *\n * The most distant neighbours go first. The edges and the current page carry no distance,\n * so no rule here can reach them. `display: none` also takes a hidden pill out of the tab\n * order and the a11y tree, which is intended. No ellipsis replaces one either — it is\n * exactly as wide as the pill it would stand for, so it frees nothing.\n */\n @container v-pagination (max-inline-size: 36rem) {\n .v-pagination[data-responsive] .v-pagination-page[data-distance='3'] {\n display: none;\n }\n }\n\n @container v-pagination (max-inline-size: 31rem) {\n .v-pagination[data-responsive] .v-pagination-page[data-distance='2'] {\n display: none;\n }\n\n /* A control showing both its icon and its label is about four times as wide as a\n pill, so dropping its label frees more room than sacrificing another page —\n which is why it happens before the last step. It is never done when the control\n shows text alone: there would be nothing left to click. */\n .v-pagination[data-responsive][data-controls='both'] .v-pagination-control-label {\n display: none;\n }\n }\n\n @container v-pagination (max-inline-size: 25rem) {\n .v-pagination[data-responsive] .v-pagination-page[data-distance='1'] {\n display: none;\n }\n }\n\n /*\n * The frame of an `outline` row: the colour its other pages already paint. They are drawn\n * in the NEUTRAL tone, so this is what their `--tone-border-soft` resolves to; naming that\n * variable here instead would read the CURRENT page's tone and tint one segment of the\n * frame accent or red. A disabled page greys its outline to a different token, hence a\n * variable the second rule changes. VToggle's `--toggle-frame` is the same device.\n *\n * `soft` and `ghost` leave VButton's border transparent, which in an outline row opens a\n * gap in the frame for the whole width of the current page (the `SelectedVariants` play\n * function). Restoring it on all four sides keeps the frame closed wherever the current\n * page sits; seamless, the shared edges are cleared again by VButtonGroup's own (0,6,0)\n * rules, which these (0,5,0) ones stay below.\n */\n .v-pagination[data-item-variant='outline'] {\n --pagination-frame: var(--vectis-color-border-strong);\n }\n\n .v-pagination[data-item-variant='outline']\n > *\n > .v-pagination-page:is(:disabled, [aria-disabled='true']) {\n --pagination-frame: var(--vectis-color-border);\n }\n\n .v-pagination[data-item-variant='outline']\n > *\n > .v-pagination-page[aria-current='page']:is([data-variant='soft'], [data-variant='ghost']) {\n border-color: var(--pagination-frame);\n }\n\n /*\n * Windows forced colors replace every background with the page's, so the current page would look\n * exactly like the others there: the fill is its only cue, the state being in an ARIA\n * attribute a sighted reader never sees. It takes the system's own selection pair\n * instead, and opts out of the forcing for that element alone so the pair is painted.\n *\n * TRAP — the class is repeated to reach (0,6,0). The variant, hover and active rules\n * reach (0,5,0), and with the forcing turned off any of them that still won would paint\n * its tone over the selection, with HighlightText on top of it.\n */\n @media (forced-colors: active) {\n .v-pagination-page.v-pagination-page.v-pagination-page.v-pagination-page[aria-current='page']:not(\n :disabled,\n [aria-disabled='true']\n ) {\n forced-color-adjust: none;\n background-color: Highlight;\n color: HighlightText;\n border-color: Highlight;\n outline-color: CanvasText;\n }\n }\n}\n</style>\n"],"mappings":""}
@@ -11,15 +11,15 @@ import { more_horiz as l } from "../VIcon/icons/more_horiz.js";
11
11
  import { arrowNavigate as u, navigableItems as d } from "../../utils/arrowNav.js";
12
12
  import { clamp as f } from "../../utils/number.js";
13
13
  import { resolveMatcher as p } from "../../utils/matcher.js";
14
- import { Fragment as m, computed as h, createBlock as g, createCommentVNode as _, createElementBlock as v, createTextVNode as y, createVNode as b, defineComponent as x, h as S, mergeModels as C, nextTick as w, openBlock as T, ref as E, renderList as D, toDisplayString as O, unref as k, useModel as A, withCtx as j } from "vue";
14
+ import { Fragment as m, computed as h, createBlock as g, createCommentVNode as _, createElementBlock as v, createTextVNode as y, createVNode as b, defineComponent as x, h as S, mergeModels as C, nextTick as w, openBlock as T, ref as E, renderList as ee, toDisplayString as D, unref as O, useModel as k, withCtx as A } from "vue";
15
15
  //#region src/components/VPagination/VPagination.vue?vue&type=script&setup=true&lang.ts
16
- var M = [
16
+ var j = [
17
17
  "aria-label",
18
18
  "data-align",
19
19
  "data-item-variant",
20
20
  "data-controls",
21
21
  "data-responsive"
22
- ], N = /*@__PURE__*/ x({
22
+ ], M = /*@__PURE__*/ x({
23
23
  __name: "VPagination",
24
24
  props: /*@__PURE__*/ C({
25
25
  length: { default: 1 },
@@ -69,16 +69,20 @@ var M = [
69
69
  pageLabel: {
70
70
  type: Function,
71
71
  default: void 0
72
+ },
73
+ href: {
74
+ type: Function,
75
+ default: void 0
72
76
  }
73
77
  }, {
74
78
  modelValue: { default: 1 },
75
79
  modelModifiers: {}
76
80
  }),
77
- emits: ["update:modelValue"],
78
- setup(s, { expose: c }) {
79
- let x = s, C = e(), N = a(() => x.label ?? C.value.pagination.label), P = h(() => x.prevText ?? C.value.pagination.previous), F = h(() => x.nextText ?? C.value.pagination.next), I = A(s, "modelValue"), L = h(() => Math.max(Math.trunc(x.length) || 1, 1)), R = h(() => f(Math.round(I.value) || 1, 1, L.value)), z = h(() => p(x.disabledPages));
81
+ emits: /*@__PURE__*/ C(["navigate"], ["update:modelValue"]),
82
+ setup(s, { expose: c, emit: x }) {
83
+ let C = s, M = x, N = e(), P = a(() => C.label ?? N.value.pagination.label), F = h(() => C.prevText ?? N.value.pagination.previous), te = h(() => C.nextText ?? N.value.pagination.next), I = k(s, "modelValue"), L = h(() => Math.max(Math.trunc(C.length) || 1, 1)), R = h(() => f(Math.round(I.value) || 1, 1, L.value)), z = h(() => p(C.disabledPages));
80
84
  function B(e) {
81
- return x.pageLabel ? x.pageLabel(e) : C.value.pagination.page(e);
85
+ return C.pageLabel ? C.pageLabel(e) : N.value.pagination.page(e);
82
86
  }
83
87
  let V = h(() => {
84
88
  let e = L.value, t = R.value, n = (n) => ({
@@ -94,7 +98,7 @@ var M = [
94
98
  }, i = (e) => ({
95
99
  kind: "gap",
96
100
  key: `gap-${e}`
97
- }), a = Math.trunc(x.totalVisible ?? e), o = Number.isNaN(a) ? e : Math.max(a, 5);
101
+ }), a = Math.trunc(C.totalVisible ?? e), o = Number.isNaN(a) ? e : Math.max(a, 5);
98
102
  if (o >= e) return r(1, e);
99
103
  let s = t - Math.floor((o - 5) / 2), c = s + (o - 5);
100
104
  return s <= 3 ? [
@@ -116,53 +120,67 @@ var M = [
116
120
  function H(e) {
117
121
  for (let t = R.value + e; t >= 1 && t <= L.value; t += e) if (!z.value(t)) return t;
118
122
  }
119
- let U = h(() => H(-1)), W = h(() => H(1)), G = h(() => x.disabled || U.value === void 0), K = h(() => x.disabled || W.value === void 0);
123
+ let U = h(() => H(-1)), W = h(() => H(1)), G = h(() => C.disabled || U.value === void 0), K = h(() => C.disabled || W.value === void 0);
120
124
  function q(e) {
121
125
  e !== void 0 && (I.value = f(e, 1, L.value));
122
126
  }
123
- async function J(e, t) {
127
+ function J(e) {
128
+ return C.href?.(e);
129
+ }
130
+ function Y(e) {
131
+ return C.href !== void 0 && (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey);
132
+ }
133
+ function X(e, t) {
134
+ t === void 0 || Y(e) || (M("navigate", t, e), q(t));
135
+ }
136
+ function Z(e) {
137
+ return e.matches(":disabled, [aria-disabled=\"true\"]");
138
+ }
139
+ async function ne(e, t) {
124
140
  let n = e.currentTarget, r = n === document.activeElement;
125
- q(t), r && (await w(), n.disabled && X.value?.querySelector("[aria-current=\"page\"]")?.focus());
141
+ X(e, t), r && (await w(), Z(n) && $.value?.querySelector("[aria-current=\"page\"]")?.focus());
126
142
  }
127
- let Y = ({ side: e }) => {
128
- let a = e === "prev", o = a ? P.value : F.value, s = a ? x.prevIcon : x.nextIcon, c = {
143
+ let Q = ({ side: e }) => {
144
+ let a = e === "prev", o = a ? F.value : te.value, s = a ? C.prevIcon : C.nextIcon, c = a ? U.value : W.value, l = a ? G.value : K.value, u = {
129
145
  class: "v-pagination-control",
130
- variant: x.itemVariant,
131
- disabled: a ? G.value : K.value,
132
- onClick: (e) => J(e, a ? U.value : W.value)
133
- }, l = () => S(n, {
146
+ variant: C.itemVariant,
147
+ disabled: l,
148
+ href: J(c ?? R.value),
149
+ rel: C.href && !l ? e : void 0,
150
+ onClick: (e) => ne(e, c)
151
+ }, d = () => S(n, {
134
152
  ...t(s),
135
153
  mirrored: !0
136
154
  });
137
- return x.controls === "icon" ? S(i, {
138
- ...c,
155
+ return C.controls === "icon" ? S(i, {
156
+ ...u,
139
157
  label: o
140
- }, l) : S(r, {
141
- ...c,
158
+ }, d) : S(r, {
159
+ ...u,
142
160
  tone: "neutral",
143
161
  "aria-label": o
144
162
  }, {
145
- ...x.controls === "both" ? { [a ? "start" : "end"]: l } : {},
163
+ ...C.controls === "both" ? { [a ? "start" : "end"]: d } : {},
146
164
  default: () => S("span", { class: "v-pagination-control-label" }, o)
147
165
  });
148
- }, X = E(null);
149
- function Z(e) {
150
- let t = X.value;
151
- t && u(e, t, () => d(t, ".v-pagination-page:not(:disabled)"));
166
+ }, $ = E(null);
167
+ function re(e) {
168
+ let t = $.value;
169
+ t && u(e, t, () => d(t, ".v-pagination-page:not(:disabled, [aria-disabled=\"true\"])"));
152
170
  }
153
171
  return c({
154
- focus: (e) => (X.value?.querySelector("[aria-current=\"page\"]:not(:disabled)") ?? X.value?.querySelector("button:not(:disabled)"))?.focus(e),
155
- el: X
172
+ focus: (e) => ($.value?.querySelector("[aria-current=\"page\"]:is(button, a[href]):not(:disabled)") ?? $.value?.querySelector(":is(button, a[href]):not(:disabled)"))?.focus(e),
173
+ el: $
156
174
  }), (e, t) => (T(), v("nav", {
157
175
  ref_key: "navEl",
158
- ref: X,
176
+ ref: $,
159
177
  class: "v-pagination",
160
- "aria-label": k(N),
178
+ "aria-label": O(P),
161
179
  "data-align": s.align,
162
180
  "data-item-variant": s.itemVariant,
163
181
  "data-controls": s.controls || void 0,
164
182
  "data-responsive": s.responsive ? "" : void 0,
165
- onKeydown: Z
183
+ onKeydown: re
166
184
  }, [b(o, {
167
185
  class: "v-pagination-items",
168
186
  detached: s.detached,
@@ -171,12 +189,12 @@ var M = [
171
189
  compact: s.compact || void 0,
172
190
  elevated: s.elevated || void 0
173
191
  }, {
174
- default: j(() => [
175
- s.controls ? (T(), g(Y, {
192
+ default: A(() => [
193
+ s.controls ? (T(), g(Q, {
176
194
  key: 0,
177
195
  side: "prev"
178
196
  })) : _("", !0),
179
- (T(!0), v(m, null, D(V.value, (e) => (T(), v(m, { key: e.key }, [e.kind === "page" ? (T(), g(r, {
197
+ (T(!0), v(m, null, ee(V.value, (e) => (T(), v(m, { key: e.key }, [e.kind === "page" ? (T(), g(r, {
180
198
  key: 0,
181
199
  class: "v-pagination-page",
182
200
  variant: e.page === R.value ? s.selectedVariant : s.itemVariant,
@@ -184,11 +202,12 @@ var M = [
184
202
  disabled: s.disabled || z.value(e.page),
185
203
  "aria-label": B(e.page),
186
204
  "aria-current": e.page === R.value ? "page" : void 0,
205
+ href: J(e.page),
187
206
  "data-edge": e.edge ? "" : void 0,
188
207
  "data-distance": !e.edge && e.distance > 0 ? e.distance : void 0,
189
- onClick: (t) => q(e.page)
208
+ onClick: (t) => X(t, e.page)
190
209
  }, {
191
- default: j(() => [y(O(e.page), 1)]),
210
+ default: A(() => [y(D(e.page), 1)]),
192
211
  _: 2
193
212
  }, 1032, [
194
213
  "variant",
@@ -196,6 +215,7 @@ var M = [
196
215
  "disabled",
197
216
  "aria-label",
198
217
  "aria-current",
218
+ "href",
199
219
  "data-edge",
200
220
  "data-distance",
201
221
  "onClick"
@@ -208,10 +228,10 @@ var M = [
208
228
  tone: "neutral",
209
229
  disabled: ""
210
230
  }, {
211
- start: j(() => [b(n, { name: k(l) }, null, 8, ["name"])]),
231
+ start: A(() => [b(n, { name: O(l) }, null, 8, ["name"])]),
212
232
  _: 1
213
233
  }, 8, ["variant"]))], 64))), 128)),
214
- s.controls ? (T(), g(Y, {
234
+ s.controls ? (T(), g(Q, {
215
235
  key: 1,
216
236
  side: "next"
217
237
  })) : _("", !0)
@@ -223,10 +243,10 @@ var M = [
223
243
  "size",
224
244
  "compact",
225
245
  "elevated"
226
- ])], 40, M));
246
+ ])], 40, j));
227
247
  }
228
248
  });
229
249
  //#endregion
230
- export { N as default };
250
+ export { M as default };
231
251
 
232
252
  //# sourceMappingURL=VPagination.vue_vue_type_script_setup_true_lang.js.map