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":"VPagination.vue_vue_type_script_setup_true_lang.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":"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"}
1
+ {"version":3,"file":"VPagination.vue_vue_type_script_setup_true_lang.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":"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"}
@@ -1,2 +1,2 @@
1
1
  @layer vectis.reset, vectis.tokens, vectis.components, vectis.utilities;
2
- @layer vectis.components{.v-slider{--slider-thumb:var(--vectis-control-size-slider-thumb);--slider-track:var(--vectis-control-size-slider-track);align-items:center;column-gap:var(--vectis-space-2);row-gap:var(--vectis-space-1);inline-size:100%;font-family:var(--vectis-text-family);grid-template-columns:minmax(0,1fr);grid-template-areas:"rail";display:grid}.v-slider:has(.v-slider-labels){grid-template-areas:"rail""labels"}.v-slider:has(.v-slider-field-end){grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"rail field-end"}.v-slider:has(.v-slider-field-end):has(.v-slider-labels){grid-template-areas:"rail field-end""labels."}.v-slider:has(.v-slider-field-start){grid-template-columns:auto minmax(0,1fr) auto;grid-template-areas:"field-start rail field-end"}.v-slider:has(.v-slider-field-start):has(.v-slider-labels){grid-template-areas:"field-start rail field-end"".labels."}.v-slider-rail{block-size:var(--slider-thumb);grid-area:rail;position:relative}.v-slider-control{position:absolute;inset:0}.v-slider-track{block-size:var(--slider-track);border-radius:var(--vectis-radius-pill);background:var(--vectis-color-border);margin-block:auto;position:absolute;inset-block:0;inset-inline:0;overflow:hidden}.v-slider-fill,.v-slider-tick,.v-slider-tooltip,.v-slider-label{--slider-at:calc(var(--slider-thumb) / 2 + (100% - var(--slider-thumb)) * var(--fill-fraction))}.v-slider-fill{--fill-fraction:var(--slider-end-fraction);inline-size:var(--slider-at);background:var(--vectis-color-accent);position:absolute;inset-block:0;inset-inline-start:0}.v-slider[data-range] .v-slider-fill{--fill-fraction:var(--slider-start-fraction);inline-size:calc((100% - var(--slider-thumb)) * (var(--slider-end-fraction) - var(--slider-start-fraction)));inset-inline-start:var(--slider-at)}.v-slider-tick{--slider-tick:calc(var(--slider-track) / 3 * 2);inline-size:var(--slider-tick);block-size:var(--slider-tick);border-radius:var(--vectis-radius-pill);background:var(--vectis-color-border-strong);margin-block:auto;position:absolute;inset-block:0;inset-inline-start:calc(var(--slider-at) - var(--slider-tick) / 2)}.v-slider-tick[data-filled]{background:var(--vectis-color-text-on-accent)}.v-slider-input{--slider-thumb-bg:var(--vectis-color-text-on-accent);--slider-thumb-border:var(--vectis-color-accent);--slider-thumb-shadow:var(--vectis-shadow-xs);--slider-thumb-outline:none;--slider-thumb-cursor:pointer;--slider-thumb-transition:background-color var(--vectis-duration-fast) var(--vectis-ease-default);appearance:none;pointer-events:none;background:0 0;width:100%;height:100%;margin:0;position:absolute;inset:0}.v-slider:not([data-range]) .v-slider-input{pointer-events:auto;cursor:pointer}.v-slider-input::-webkit-slider-thumb{appearance:none;pointer-events:auto;width:var(--slider-thumb);height:var(--slider-thumb);border-radius:var(--vectis-radius-pill);background:var(--slider-thumb-bg);border:var(--vectis-control-border-width) solid var(--slider-thumb-border);box-shadow:var(--slider-thumb-shadow);outline:var(--slider-thumb-outline);outline-offset:var(--vectis-focus-ring-offset);cursor:var(--slider-thumb-cursor);transition:var(--slider-thumb-transition)}.v-slider-input::-moz-range-thumb{pointer-events:auto;width:var(--slider-thumb);height:var(--slider-thumb);border-radius:var(--vectis-radius-pill);background:var(--slider-thumb-bg);border:var(--vectis-control-border-width) solid var(--slider-thumb-border);box-shadow:var(--slider-thumb-shadow);outline:var(--slider-thumb-outline);outline-offset:var(--vectis-focus-ring-offset);cursor:var(--slider-thumb-cursor);transition:var(--slider-thumb-transition)}.v-slider-input:is(:hover,:active):not(:disabled){--slider-thumb-bg:color-mix(in oklab, var(--vectis-color-text-on-accent), var(--vectis-color-accent) 10%)}.v-slider-input:focus-visible{--slider-thumb-outline:var(--vectis-focus-ring-width) solid var(--vectis-focus-ring-color);outline:none}.v-slider[data-readonly] .v-slider-input{--slider-thumb-bg:var(--vectis-color-surface);--slider-thumb-border:var(--vectis-color-text-muted);--slider-thumb-cursor:default;cursor:default}.v-slider[data-readonly] .v-slider-fill{background:var(--vectis-color-text-muted)}.v-slider[data-readonly] .v-slider-tick[data-filled]{background:var(--vectis-color-surface)}.v-slider[data-invalid] .v-slider-input{--slider-thumb-border:var(--vectis-color-danger)}.v-slider-tooltip{opacity:0;visibility:hidden;inline-size:0;transition:opacity var(--vectis-duration-fast) var(--vectis-ease-default), visibility var(--vectis-duration-fast) var(--vectis-ease-default);justify-content:center;display:flex;position:absolute;inset-block-end:calc(100% + var(--vectis-space-2));inset-inline-start:var(--slider-at)}.v-slider-tooltip-start{--fill-fraction:var(--slider-start-fraction)}.v-slider-tooltip-end{--fill-fraction:var(--slider-end-fraction)}.v-slider:has(.v-slider-input-start:active) .v-slider-tooltip-start,.v-slider:has(.v-slider-input-start:focus-visible) .v-slider-tooltip-start,.v-slider:has(.v-slider-input-end:active) .v-slider-tooltip-end,.v-slider:has(.v-slider-input-end:focus-visible) .v-slider-tooltip-end{opacity:1;visibility:visible}.v-slider-tooltip-bubble{padding:var(--vectis-space-1) var(--vectis-space-2);background:var(--vectis-color-surface-inverse);color:var(--vectis-color-text-on-inverse);font-size:var(--vectis-text-caption-size);line-height:var(--vectis-text-caption-leading);border-radius:min(var(--vectis-radius-interactive), calc(.5lh + var(--vectis-space-1)));box-shadow:var(--vectis-shadow-sm);white-space:nowrap}.v-slider-labels{min-block-size:var(--vectis-icon-size-md);--vectis-icon-size:var(--vectis-icon-size-md);--vectis-icon-opsz:20;font-size:var(--vectis-text-caption-size);color:var(--vectis-color-text-muted);grid-area:labels;position:relative}.v-slider-label{white-space:nowrap;justify-content:center;inline-size:0;display:flex;position:absolute;inset-block-start:0;inset-inline-start:var(--slider-at)}.v-slider-hint{font-size:var(--vectis-text-caption-size);line-height:var(--vectis-text-caption-leading);color:var(--vectis-color-text-muted);grid-column:1/-1}.v-slider[data-disabled] .v-slider-hint{color:var(--vectis-color-text-subtle)}.v-slider-field.v-input{inline-size:var(--vectis-control-size-slider-field)}.v-slider-field-start.v-input{grid-area:field-start}.v-slider-field-end.v-input{grid-area:field-end}.v-slider[data-orientation=vertical]{grid-template-columns:none;grid-template-areas:"rail";justify-items:center;inline-size:fit-content}.v-slider[data-orientation=vertical]:has(.v-slider-labels){grid-template-columns:auto auto;grid-template-areas:"rail labels"}.v-slider[data-orientation=vertical]:has(.v-slider-field-end){grid-template-columns:none;grid-template-areas:"field-end""rail"}.v-slider[data-orientation=vertical]:has(.v-slider-field-end):has(.v-slider-labels){grid-template-columns:auto auto;grid-template-areas:"field-end.""rail labels"}.v-slider[data-orientation=vertical]:has(.v-slider-field-start){grid-template-columns:none;grid-template-areas:"field-end""rail""field-start"}.v-slider[data-orientation=vertical]:has(.v-slider-field-start):has(.v-slider-labels){grid-template-columns:auto auto;grid-template-areas:"field-end.""rail labels""field-start."}.v-slider[data-orientation=vertical] .v-slider-rail{inline-size:var(--slider-thumb);block-size:var(--vectis-control-size-slider-length)}.v-slider[data-orientation=vertical] .v-slider-control{writing-mode:vertical-lr;direction:rtl}.v-slider[data-orientation=vertical] .v-slider-tooltip{justify-content:flex-end;align-items:center;block-size:0;inline-size:auto;inset-block-end:var(--slider-at);inset-inline-start:auto;inset-inline-end:calc(100% + var(--vectis-space-2))}.v-slider[data-orientation=vertical] .v-slider-labels{align-self:stretch;min-block-size:auto}.v-slider[data-orientation=vertical] .v-slider-label{justify-content:flex-start;align-items:center;block-size:0;inline-size:auto;inset-block-start:auto;inset-block-end:var(--slider-at);inset-inline-start:0}.v-slider[data-disabled]{cursor:not-allowed}.v-slider[data-disabled] .v-slider-track{background:var(--vectis-color-surface-muted)}.v-slider[data-disabled] .v-slider-fill,.v-slider[data-disabled] .v-slider-tick{background:var(--vectis-color-text-subtle)}.v-slider[data-disabled] .v-slider-tick[data-filled]{background:var(--vectis-color-surface-muted)}.v-slider[data-disabled] .v-slider-labels{color:var(--vectis-color-text-subtle)}.v-slider[data-disabled] .v-slider-input{--slider-thumb-bg:var(--vectis-color-surface-muted);--slider-thumb-border:var(--vectis-color-text-subtle);--slider-thumb-shadow:none;--slider-thumb-cursor:not-allowed;cursor:not-allowed}@media (forced-colors:active){.v-slider .v-slider-track{forced-color-adjust:none;box-shadow:inset 0 0 0 var(--vectis-control-border-width) CanvasText;background:canvas}.v-slider .v-slider-fill{forced-color-adjust:none;background:highlight}.v-slider .v-slider-tick{forced-color-adjust:none;background:canvastext}.v-slider .v-slider-tick[data-filled]{background:highlighttext}.v-slider .v-slider-input{forced-color-adjust:none;--slider-thumb-bg:Canvas;--slider-thumb-border:CanvasText;--slider-thumb-shadow:none}.v-slider[data-disabled] .v-slider-track{box-shadow:inset 0 0 0 var(--vectis-control-border-width) GrayText}.v-slider[data-disabled] .v-slider-fill,.v-slider[data-disabled] .v-slider-tick{background:graytext}.v-slider[data-disabled] .v-slider-input{--slider-thumb-border:GrayText}}@media (prefers-reduced-motion:reduce){.v-slider-input{--slider-thumb-transition:none}.v-slider-tooltip{transition:none}}}
2
+ @layer vectis.components{.v-slider{--slider-thumb:var(--vectis-control-size-slider-thumb);--slider-track:var(--vectis-control-size-slider-track);align-items:center;column-gap:var(--vectis-space-2);row-gap:var(--vectis-space-1);inline-size:100%;font-family:var(--vectis-text-family);grid-template-columns:minmax(0,1fr);grid-template-areas:"rail";display:grid}.v-slider:has(.v-slider-labels){grid-template-areas:"rail""labels"}.v-slider[data-inputs=ends]{grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"rail field-end"}.v-slider[data-inputs=ends]:has(.v-slider-labels){grid-template-areas:"rail field-end""labels."}.v-slider[data-inputs=ends][data-range]{grid-template-columns:auto minmax(0,1fr) auto;grid-template-areas:"field-start rail field-end"}.v-slider[data-inputs=ends][data-range]:has(.v-slider-labels){grid-template-areas:"field-start rail field-end"".labels."}.v-slider[data-inputs=top]{grid-template-columns:auto minmax(0,1fr) auto;grid-template-areas:"field-start.field-end""rail rail rail"}.v-slider[data-inputs=top]:has(.v-slider-labels){grid-template-areas:"field-start.field-end""rail rail rail""labels labels labels"}.v-slider[data-inputs=bottom]{grid-template-columns:auto minmax(0,1fr) auto;grid-template-areas:"rail rail rail""field-start.field-end"}.v-slider[data-inputs=bottom]:has(.v-slider-labels){grid-template-areas:"rail rail rail""labels labels labels""field-start.field-end"}.v-slider-rail{block-size:var(--slider-thumb);grid-area:rail;position:relative}.v-slider-control{position:absolute;inset:0}.v-slider-track{block-size:var(--slider-track);border-radius:var(--vectis-radius-pill);background:var(--vectis-color-border);margin-block:auto;position:absolute;inset-block:0;inset-inline:0;overflow:hidden}.v-slider-fill,.v-slider-tick,.v-slider-tooltip,.v-slider-label{--slider-at:calc(var(--slider-thumb) / 2 + (100% - var(--slider-thumb)) * var(--fill-fraction))}.v-slider-fill{--fill-fraction:var(--slider-end-fraction);inline-size:var(--slider-at);background:var(--vectis-color-accent);position:absolute;inset-block:0;inset-inline-start:0}.v-slider[data-range] .v-slider-fill{--fill-fraction:var(--slider-start-fraction);inline-size:calc((100% - var(--slider-thumb)) * (var(--slider-end-fraction) - var(--slider-start-fraction)));inset-inline-start:var(--slider-at)}.v-slider-tick{--slider-tick:calc(var(--slider-track) / 3 * 2);inline-size:var(--slider-tick);block-size:var(--slider-tick);border-radius:var(--vectis-radius-pill);background:var(--vectis-color-border-strong);margin-block:auto;position:absolute;inset-block:0;inset-inline-start:calc(var(--slider-at) - var(--slider-tick) / 2)}.v-slider-tick[data-filled]{background:var(--vectis-color-text-on-accent)}.v-slider-input{--slider-thumb-bg:var(--vectis-color-text-on-accent);--slider-thumb-border:var(--vectis-color-accent);--slider-thumb-shadow:var(--vectis-shadow-xs);--slider-thumb-outline:none;--slider-thumb-cursor:pointer;--slider-thumb-transition:background-color var(--vectis-duration-fast) var(--vectis-ease-default);appearance:none;pointer-events:none;background:0 0;width:100%;height:100%;margin:0;position:absolute;inset:0}.v-slider:not([data-range]) .v-slider-input{pointer-events:auto;cursor:pointer}.v-slider-input::-webkit-slider-thumb{appearance:none;pointer-events:auto;width:var(--slider-thumb);height:var(--slider-thumb);border-radius:var(--vectis-radius-pill);background:var(--slider-thumb-bg);border:var(--vectis-control-border-width) solid var(--slider-thumb-border);box-shadow:var(--slider-thumb-shadow);outline:var(--slider-thumb-outline);outline-offset:var(--vectis-focus-ring-offset);cursor:var(--slider-thumb-cursor);transition:var(--slider-thumb-transition)}.v-slider-input::-moz-range-thumb{pointer-events:auto;width:var(--slider-thumb);height:var(--slider-thumb);border-radius:var(--vectis-radius-pill);background:var(--slider-thumb-bg);border:var(--vectis-control-border-width) solid var(--slider-thumb-border);box-shadow:var(--slider-thumb-shadow);outline:var(--slider-thumb-outline);outline-offset:var(--vectis-focus-ring-offset);cursor:var(--slider-thumb-cursor);transition:var(--slider-thumb-transition)}.v-slider-input:is(:hover,:active):not(:disabled){--slider-thumb-bg:color-mix(in oklab, var(--vectis-color-text-on-accent), var(--vectis-color-accent) 10%)}.v-slider-input:focus-visible{--slider-thumb-outline:var(--vectis-focus-ring-width) solid var(--vectis-focus-ring-color);outline:none}.v-slider[data-readonly] .v-slider-input{--slider-thumb-bg:var(--vectis-color-surface);--slider-thumb-border:var(--vectis-color-text-muted);--slider-thumb-cursor:default;cursor:default}.v-slider[data-readonly] .v-slider-fill{background:var(--vectis-color-text-muted)}.v-slider[data-readonly] .v-slider-tick[data-filled]{background:var(--vectis-color-surface)}.v-slider[data-invalid] .v-slider-input{--slider-thumb-border:var(--vectis-color-danger)}.v-slider-tooltip{opacity:0;visibility:hidden;inline-size:0;transition:opacity var(--vectis-duration-fast) var(--vectis-ease-default), visibility var(--vectis-duration-fast) var(--vectis-ease-default);justify-content:center;display:flex;position:absolute;inset-block-end:calc(100% + var(--vectis-space-2));inset-inline-start:var(--slider-at)}.v-slider-tooltip-start{--fill-fraction:var(--slider-start-fraction)}.v-slider-tooltip-end{--fill-fraction:var(--slider-end-fraction)}.v-slider:has(.v-slider-input-start:active) .v-slider-tooltip-start,.v-slider:has(.v-slider-input-start:focus-visible) .v-slider-tooltip-start,.v-slider:has(.v-slider-input-end:active) .v-slider-tooltip-end,.v-slider:has(.v-slider-input-end:focus-visible) .v-slider-tooltip-end{opacity:1;visibility:visible}.v-slider-tooltip-bubble{padding:var(--vectis-space-1) var(--vectis-space-2);background:var(--vectis-color-surface-inverse);color:var(--vectis-color-text-on-inverse);font-size:var(--vectis-text-caption-size);line-height:var(--vectis-text-caption-leading);border-radius:min(var(--vectis-radius-interactive), calc(.5lh + var(--vectis-space-1)));box-shadow:var(--vectis-shadow-sm);white-space:nowrap}.v-slider-labels{min-block-size:var(--vectis-icon-size-md);--vectis-icon-size:var(--vectis-icon-size-md);--vectis-icon-opsz:20;font-size:var(--vectis-text-caption-size);color:var(--vectis-color-text-muted);grid-area:labels;position:relative}.v-slider-label{white-space:nowrap;justify-content:center;inline-size:0;display:flex;position:absolute;inset-block-start:0;inset-inline-start:var(--slider-at)}.v-slider-hint{font-size:var(--vectis-text-caption-size);line-height:var(--vectis-text-caption-leading);color:var(--vectis-color-text-muted);grid-column:1/-1}.v-slider[data-disabled] .v-slider-hint{color:var(--vectis-color-text-subtle)}.v-slider-field.v-input{inline-size:var(--vectis-control-size-slider-field)}.v-slider-field-start.v-input{grid-area:field-start}.v-slider-field-end.v-input{grid-area:field-end}.v-slider[data-orientation=vertical]{grid-template-columns:none;grid-template-areas:"rail";justify-items:center;inline-size:fit-content}.v-slider[data-orientation=vertical]:has(.v-slider-labels){grid-template-columns:auto auto;grid-template-areas:"rail labels"}.v-slider[data-orientation=vertical][data-inputs=ends]{grid-template-columns:none;grid-template-areas:"field-end""rail"}.v-slider[data-orientation=vertical][data-inputs=ends]:has(.v-slider-labels){grid-template-columns:auto auto;grid-template-areas:"field-end.""rail labels"}.v-slider[data-orientation=vertical][data-inputs=ends][data-range]{grid-template-columns:none;grid-template-areas:"field-end""rail""field-start"}.v-slider[data-orientation=vertical][data-inputs=ends][data-range]:has(.v-slider-labels){grid-template-columns:auto auto;grid-template-areas:"field-end.""rail labels""field-start."}.v-slider[data-orientation=vertical][data-inputs=top]{grid-template:"field-end rail"".rail"1fr"field-start rail"/auto auto}.v-slider[data-orientation=vertical][data-inputs=top]:has(.v-slider-labels){grid-template-columns:auto auto auto;grid-template-areas:"field-end rail labels"".rail labels""field-start rail labels"}.v-slider[data-orientation=vertical][data-inputs=bottom]{grid-template:"rail field-end""rail."1fr"rail field-start"/auto auto}.v-slider[data-orientation=vertical][data-inputs=bottom]:has(.v-slider-labels){grid-template-columns:auto auto auto;grid-template-areas:"rail labels field-end""rail labels.""rail labels field-start"}.v-slider[data-orientation=vertical] .v-slider-rail{inline-size:var(--slider-thumb);block-size:var(--vectis-control-size-slider-length)}.v-slider[data-orientation=vertical] .v-slider-control{writing-mode:vertical-lr;direction:rtl}.v-slider[data-orientation=vertical] .v-slider-tooltip{justify-content:flex-end;align-items:center;block-size:0;inline-size:auto;inset-block-end:var(--slider-at);inset-inline-start:auto;inset-inline-end:calc(100% + var(--vectis-space-2))}.v-slider[data-orientation=vertical] .v-slider-labels{align-self:stretch;min-block-size:auto}.v-slider[data-orientation=vertical] .v-slider-label{justify-content:flex-start;align-items:center;block-size:0;inline-size:auto;inset-block-start:auto;inset-block-end:var(--slider-at);inset-inline-start:0}.v-slider[data-disabled]{cursor:not-allowed}.v-slider[data-disabled] .v-slider-track{background:var(--vectis-color-surface-muted)}.v-slider[data-disabled] .v-slider-fill,.v-slider[data-disabled] .v-slider-tick{background:var(--vectis-color-text-subtle)}.v-slider[data-disabled] .v-slider-tick[data-filled]{background:var(--vectis-color-surface-muted)}.v-slider[data-disabled] .v-slider-labels{color:var(--vectis-color-text-subtle)}.v-slider[data-disabled] .v-slider-input{--slider-thumb-bg:var(--vectis-color-surface-muted);--slider-thumb-border:var(--vectis-color-text-subtle);--slider-thumb-shadow:none;--slider-thumb-cursor:not-allowed;cursor:not-allowed}@media (forced-colors:active){.v-slider .v-slider-track{forced-color-adjust:none;box-shadow:inset 0 0 0 var(--vectis-control-border-width) CanvasText;background:canvas}.v-slider .v-slider-fill{forced-color-adjust:none;background:highlight}.v-slider .v-slider-tick{forced-color-adjust:none;background:canvastext}.v-slider .v-slider-tick[data-filled]{background:highlighttext}.v-slider .v-slider-input{forced-color-adjust:none;--slider-thumb-bg:Canvas;--slider-thumb-border:CanvasText;--slider-thumb-shadow:none}.v-slider[data-disabled] .v-slider-track{box-shadow:inset 0 0 0 var(--vectis-control-border-width) GrayText}.v-slider[data-disabled] .v-slider-fill,.v-slider[data-disabled] .v-slider-tick{background:graytext}.v-slider[data-disabled] .v-slider-input{--slider-thumb-border:GrayText}}@media (prefers-reduced-motion:reduce){.v-slider-input{--slider-thumb-transition:none}.v-slider-tooltip{transition:none}}}
@@ -13,6 +13,11 @@ export type SliderValue = number | [number, number];
13
13
  export type SliderOrientation = 'horizontal' | 'vertical';
14
14
  /** The height of the number fields: 32, 40 or 48 pixels. */
15
15
  export type SliderSize = 'sm' | 'md' | 'lg';
16
+ /**
17
+ * Where the number fields sit: none, at the ends of the track, or in a row above or below
18
+ * it. On an upright slider, above and below become the two sides of it.
19
+ */
20
+ export type SliderInputs = false | 'ends' | 'top' | 'bottom';
16
21
  interface SliderProps {
17
22
  /** The lowest value the thumb can reach. It is 0 by default. */
18
23
  min?: number;
@@ -58,10 +63,13 @@ interface SliderProps {
58
63
  /** Turns the slider upright, with the lowest value at the bottom. */
59
64
  orientation?: SliderOrientation;
60
65
  /**
61
- * Adds a number field beside the slider for setting the value exactly: one, or one per
62
- * end in range mode. Sliding is quick but imprecise; this is the way out.
66
+ * Adds a number field for setting the value exactly: one, or one per end in range mode.
67
+ * Sliding is quick but imprecise; this is the way out. `ends` puts the fields on either
68
+ * side of the track, `top` and `bottom` in a row above or below it, each field at the
69
+ * edge of the value it holds. An upright slider turns `top` and `bottom` into its start
70
+ * and end sides. No field is drawn by default.
63
71
  */
64
- inputs?: boolean;
72
+ inputs?: SliderInputs;
65
73
  /**
66
74
  * Marks each step on the track. Providing labels implies it. Past fifty steps the
67
75
  * marks would be an unreadable comb and are not drawn at all.
@@ -113,7 +121,7 @@ declare const __VLS_export: import('vue').DefineComponent<__VLS_PublicProps, {
113
121
  max: number;
114
122
  step: number;
115
123
  min: number;
116
- inputs: boolean;
124
+ inputs: SliderInputs;
117
125
  ticks: boolean;
118
126
  labels: SliderLabel[];
119
127
  }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
@@ -1 +1 @@
1
- {"version":3,"file":"VSlider.js","names":[],"sources":["../../../src/components/VSlider/VSlider.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @core\n/**\n * A value chosen by sliding a thumb along a track, optionally a range between two.\n *\n * Underneath is a native `<input type=\"range\">`, which brings the keyboard, the ARIA and the\n * form behaviour. The JS covers only what it cannot: keeping the pair of a range ordered —\n * there is no native two-thumb control, so a range is two superimposed — refusing a change\n * when `readonly`, which a range input has no native form of, feeding the optional number\n * fields, and computing the positions of the ticks and labels.\n *\n * Those positions reach the stylesheet as unitless inline fractions, and that is the whole\n * binding between the two. They matter because a thumb's CENTRE does not travel the full\n * width of the track: it runs from half a thumb in to half a thumb from the end, and\n * everything meant to line up with it has to follow that same run.\n */\n\nimport { computed, inject, ref, watch, watchEffect } from 'vue'\nimport VIcon from '../VIcon/VIcon.vue'\nimport { iconProps } from '../VIcon/iconProps'\nimport type { IconSource } from '../VIcon/types'\nimport VInput from '../VInput/VInput.vue'\nimport { inputGroupKey } from '../VInput/context'\n\nimport { useAriaLabel } from '../../composables/useAriaLabel'\nimport { useControlShape } from '../../composables/useControlShape'\nimport { useFieldIds } from '../../composables/useFieldIds'\nimport { useRootAttrs } from '../../composables/useRootAttrs'\nimport { isDev } from '../../utils/env'\nimport { clamp } from '../../utils/number'\nimport { useMessages } from '../../i18n/state'\n\n/**\n * What one step of the track is called: a piece of text, or an icon paired with the words\n * that name it, which is what a screen reader reads in place of the raw number.\n */\nexport type SliderLabel = string | { icon: IconSource; label: string }\n\n/** The value of a slider: one number, or an ordered pair of them in range mode. */\nexport type SliderValue = number | [number, number]\n\n/** Which way the track runs. */\nexport type SliderOrientation = 'horizontal' | 'vertical'\n\n/** The height of the number fields: 32, 40 or 48 pixels. */\nexport type SliderSize = 'sm' | 'md' | 'lg'\n\ninterface SliderProps {\n /** The lowest value the thumb can reach. It is 0 by default. */\n min?: number\n /** The highest value the thumb can reach. It is 100 by default. */\n max?: number\n /**\n * The gap between two values the thumb can stop on, 1 by default. It is also what the\n * arrow keys move by, and what a value typed into the companion field is snapped to.\n */\n step?: number\n /** Offers two thumbs to pick a range, which makes the v-model a pair of values. */\n range?: boolean\n /** Makes the slider unusable. */\n disabled?: boolean\n /**\n * Shows the value without allowing it to be changed. The thumbs can still be focused and\n * are announced as read-only, but neither the pointer nor the keyboard moves them, and the\n * number fields turn read-only with them.\n */\n readonly?: boolean\n /**\n * Marks the value as invalid, which colours the thumbs and tells assistive technology\n * so. It is for a rule the browser cannot check by itself.\n */\n invalid?: boolean\n /**\n * The height of the number fields `inputs` adds. Inside a VInputGroup the group's size\n * wins, as it does for every field of the row.\n */\n size?: SliderSize\n /**\n * What screen readers announce for the slider. It is an accessible name and draws\n * nothing on screen. In range mode the two thumbs are announced as the start and the\n * end of it.\n */\n label?: string\n /**\n * A line of help under the track, stating what the numbers mean or where they may go. It\n * is tied to the slider for assistive technology, so it is read out after the name rather\n * than as part of it.\n */\n hint?: string\n /** Turns the slider upright, with the lowest value at the bottom. */\n orientation?: SliderOrientation\n /**\n * Adds a number field beside the slider for setting the value exactly: one, or one per\n * end in range mode. Sliding is quick but imprecise; this is the way out.\n */\n inputs?: boolean\n /**\n * Marks each step on the track. Providing labels implies it. Past fifty steps the\n * marks would be an unreadable comb and are not drawn at all.\n */\n ticks?: boolean\n /**\n * A label for every step, in order: a piece of text, or an icon with the words that\n * name it for screen readers. They also become what a screen reader announces in\n * place of the raw number.\n */\n labels?: SliderLabel[]\n /** Shows the value in a bubble above the thumb while it is being moved or focused. */\n tooltip?: boolean\n}\n\nconst props = withDefaults(defineProps<SliderProps>(), {\n min: 0,\n max: 100,\n step: 1,\n range: false,\n disabled: false,\n readonly: false,\n invalid: false,\n size: 'md',\n label: undefined,\n hint: undefined,\n orientation: 'horizontal',\n inputs: false,\n ticks: false,\n labels: undefined,\n tooltip: false,\n})\n\n/**\n * The value: a single number, 0 to begin with, or an ordered pair once `range` is set. It\n * is that prop and not the shape of this value that decides how many thumbs are drawn.\n * The pair stays ordered whatever the reader does, a thumb taken past its sibling pushing\n * it along rather than stopping against it.\n */\nconst model = defineModel<SliderValue>({ default: 0 })\n\nconst emit = defineEmits<{\n /**\n * The value is BEING changed: every step of a drag, and every key that moves a thumb.\n * It carries the whole value, a pair in range mode, and fires for either thumb. It is\n * declared rather than left to fall through, because a range is two native controls and\n * an attribute only ever reaches one of them.\n */\n input: [value: SliderValue]\n /**\n * The reader has SETTLED on a value: a thumb was released or moved by a key, or a number\n * field was committed. It carries the whole value, a pair in range mode, and fires for\n * either thumb, where the v-model follows every step of a drag.\n */\n change: [value: SliderValue]\n}>()\n\n// The number fields are fields of a VInputGroup row like any other, so the row's size and\n// its disabled state reach them through the same arbitration. A slider has no `compact` of\n// its own: only the row's can apply.\nconst group = inject(inputGroupKey, null)\nconst {\n size: resolvedSize,\n compact: resolvedCompact,\n disabled: resolvedDisabled,\n} = useControlShape(\n {\n get size() {\n return props.size\n },\n compact: false,\n get disabled() {\n return props.disabled\n },\n },\n group,\n)\n\ntype Thumb = 'start' | 'end'\n\nconst startValue = computed(() => (Array.isArray(model.value) ? model.value[0] : props.min))\nconst endValue = computed(() =>\n Array.isArray(model.value) ? model.value[1] : (model.value as number),\n)\n\nconst thumbValue = (which: Thumb) => (which === 'start' ? startValue.value : endValue.value)\n\n/**\n * Where a value sits along the run, as a fraction between 0 and 1. The guard covers a\n * slider whose two bounds are equal, which would otherwise divide by zero.\n */\nconst frac = (v: number) => clamp((v - props.min) / (props.max - props.min || 1), 0, 1)\n\n// @core — the ONE place a thumb is written, so what keeps the pair ordered exists once for\n// the thumbs and the number fields alike. A range is two native controls laid over one\n// another, and there is no dual-thumb control to inherit this behaviour from. It returns\n// what was written, which is what each caller reads back: a model that lags a parent\n// `v-model` would still hold the value from before.\n//\n// TRAP — the thumb being held PUSHES its sibling; it must not stop against it. Two thumbs\n// resting on the same value are one thumb as far as the pointer is concerned, the end one\n// being last in the DOM and therefore the only one it can reach — so a range stopped at\n// `[100, 100]` could only ever be raised, and at the maximum it could not move at all. The\n// pair stays ordered either way; what changes is which thumb gives way.\nfunction writeThumb(which: Thumb, n: number): SliderValue {\n const next: SliderValue = !props.range\n ? n\n : which === 'start'\n ? [n, Math.max(n, endValue.value)]\n : [Math.min(n, startValue.value), n]\n model.value = next\n return next\n}\n\nconst writtenFor = (value: SliderValue, which: Thumb) =>\n Array.isArray(value) ? value[which === 'start' ? 0 : 1] : value\n\nfunction onThumbInput(which: Thumb, event: Event) {\n const el = event.target as HTMLInputElement\n // @core\n // A drag or a click on the track has ALREADY moved the native thumb when `input` fires,\n // and the event cannot be cancelled: putting the value back is the only refusal there is.\n // It lands before the browser paints, so the thumb never visibly leaves its place, and\n // since the value at release is the one it started from, no `change` follows either.\n if (props.readonly) {\n el.value = String(thumbValue(which))\n return\n }\n // Written back to the DOM as well: a thumb dragged past its sibling stops against it.\n const next = writeThumb(which, Number(el.value))\n el.value = String(writtenFor(next, which))\n emit('input', next)\n}\n\n// @keyboard @core\n// The keys that move a range input, cancelled before the browser applies them when the\n// slider is read-only. Tab and every other key pass, so the thumbs stay reachable.\nconst VALUE_KEYS = new Set([\n 'ArrowUp',\n 'ArrowDown',\n 'ArrowLeft',\n 'ArrowRight',\n 'Home',\n 'End',\n 'PageUp',\n 'PageDown',\n])\n\nfunction onThumbKeydown(event: KeyboardEvent) {\n if (props.readonly && VALUE_KEYS.has(event.key)) event.preventDefault()\n}\n\n// @core\n/*\n * A range is two native controls, and a consumer's `@change` could only ever reach ONE of\n * them through the forwarded attributes: moving the start thumb told nobody. So `change` is\n * declared and fed by both.\n *\n * TRAP — the settled value is read off the THUMB, never off the model. A key press fires\n * `input` and `change` back to back in one task, and under a parent `v-model` the model's\n * local copy only catches up when the parent re-renders, so it would still hold the value\n * from before the key.\n */\nfunction onThumbChange(which: Thumb, event: Event) {\n if (props.readonly) return\n const value = Number((event.target as HTMLInputElement).value)\n if (!props.range) emit('change', value)\n else if (which === 'start') emit('change', [value, Math.max(value, endValue.value)])\n else emit('change', [Math.min(value, startValue.value), value])\n}\n\n/**\n * How many whole steps the thumb can actually stop on. It matters when the range does\n * not divide evenly by the step: the native control stops at the last step that fits,\n * short of the maximum, and the ticks have to agree with it.\n */\nconst stepCount = computed(() =>\n props.step > 0 ? Math.floor((props.max - props.min) / props.step + 1e-9) : 0,\n)\n\n/**\n * The highest value a thumb can actually stop on, which is the last whole step and not\n * necessarily the maximum: with `min=0 max=95 step=10` the native control stops at 90.\n */\nconst lastStop = computed(() =>\n props.step > 0 ? props.min + stepCount.value * props.step : props.max,\n)\n\nconst showTicks = computed(\n () =>\n (props.ticks || props.labels !== undefined) && stepCount.value >= 1 && stepCount.value <= 50,\n)\n\n/**\n * Each step's value and the style that places it, shared by the ticks and the labels. Only\n * the bounds and the step move them, so dragging a thumb rebuilds none of it: each style\n * object keeps its identity from one render to the next, and Vue skips a binding whose\n * object has not changed. Which ticks are FILLED does follow the value, and is asked\n * separately, in `isFilled`.\n */\nconst stepPlaces = computed(() => {\n const length = Math.max(showTicks.value ? stepCount.value + 1 : 0, props.labels?.length ?? 0)\n return Array.from({ length }, (_, i) => {\n const value = props.min + i * props.step\n return { value, style: { '--fill-fraction': String(frac(value)) } }\n })\n})\n\nconst tickPlaces = computed(() =>\n showTicks.value ? stepPlaces.value.slice(0, stepCount.value + 1) : [],\n)\n\nconst isFilled = (value: number) =>\n props.range ? value >= startValue.value && value <= endValue.value : value <= endValue.value\n\n/** What a value is called, if the consumer named its step; failing that, the number itself. */\nfunction labelTextAt(value: number): string {\n const item = props.labels?.[Math.round((value - props.min) / props.step)]\n if (item === undefined) return String(value)\n return typeof item === 'string' ? item : item.label\n}\n\n// @a11y\n/* What each thumb is announced as. There is no prop for the two halves: the wording\n comes from the dictionary, which is also where it is changed.\n\n With a single thumb there is nothing to distinguish: that thumb IS the value, so it\n simply takes the resolved label, and is left unnamed when none was given. The\n generic fallback applies to the NUMBER FIELD alone, which cannot go unnamed: a bare\n field in a form has to say what it holds.\n\n TRAP — the name has to be RESOLVED against the consumer's attributes rather than read\n off the prop. The `:aria-label` below is bound AFTER the forwarded attributes, which is\n what lets a range give its two thumbs distinct names; bound from a bare `props.label`\n it ALSO overwrote an `aria-label` the consumer had written, with `undefined`, and\n `mergeProps` copies the key all the same. `useAriaLabel` is that resolution: it hands\n back the consumer's own name when there is one, and nothing at all under an\n `aria-labelledby`, which names the thumb by itself. */\nconst m = useMessages()\nconst resolvedLabel = useAriaLabel(() => props.label)\nconst startLabel = computed(() =>\n resolvedLabel.value ? m.value.slider.rangeStart(resolvedLabel.value) : m.value.slider.start,\n)\nconst endLabel = computed(() =>\n resolvedLabel.value ? m.value.slider.rangeEnd(resolvedLabel.value) : m.value.slider.end,\n)\nconst thumbEndLabel = computed(() => (props.range ? endLabel.value : resolvedLabel.value))\nconst fieldEndLabel = computed(() =>\n props.range ? endLabel.value : (resolvedLabel.value ?? m.value.slider.value),\n)\n\nconst startValueText = computed(() =>\n props.range && props.labels ? labelTextAt(startValue.value) : undefined,\n)\nconst endValueText = computed(() => (props.labels ? labelTextAt(endValue.value) : undefined))\n\n// @a11y\n/*\n * The wrapper-root split. The root here is a layout box holding the labels, the track and\n * the optional number fields, so a consumer's `name`, `id`, `required` and `aria-*` have\n * to be redirected onto the real `<input type=\"range\">` — left on the wrapper, a `name`\n * submits nothing and a `<label for>` points at a div.\n *\n * They go to the END thumb, which is the one always rendered. A RANGE has two thumbs and\n * therefore no single value to submit, which the warning below says out loud rather than\n * letting a form come back with half the answer.\n */\ndefineOptions({ inheritAttrs: false })\nconst { attrs, rootClass, rootStyle, forwardedAttrs } = useRootAttrs()\n\n// The hint is APPENDED to whatever the consumer already pointed at rather than replacing\n// it, which is why the binding below sits after the forwarded attributes, as on every\n// other field of the design system.\nconst { hintId, describedBy } = useFieldIds(attrs, () => !!props.hint)\n\n// @devwarn\n/*\n * Every guard here describes something that fails SILENTLY. They sit in an effect, so a\n * bound that only becomes wrong on a later render is still caught, and each sentence is\n * given once per instance: the same warning repeated on every keystroke is how a warning\n * stops being read (the `useFileField` arrangement).\n */\nif (isDev) {\n // Keyed by a short id rather than by the sentence: each of these embeds a count, so the\n // same guard reported twice would read as two different messages and warn again.\n const warned = new Set<string>()\n const warn = (id: string, message: string) => {\n if (warned.has(id)) return\n warned.add(id)\n console.warn(`[VSlider] ${message}`)\n }\n watchEffect(() => {\n if (props.step <= 0)\n warn(\n 'step',\n `step=\"${props.step}\" cannot move a thumb: the arrow keys do nothing and a value typed into a field is committed as it stands. Give a positive step.`,\n )\n if ((props.ticks || props.labels) && stepCount.value > 50)\n warn(\n 'ticks',\n `${stepCount.value} steps: no tick is drawn past 50, the comb being unreadable.`,\n )\n if (props.labels && props.labels.length !== stepCount.value + 1)\n warn(\n 'labels',\n `${props.labels.length} labels for ${stepCount.value + 1} steps: one label per step expected.`,\n )\n if (props.range && attrs.name !== undefined)\n warn(\n 'name',\n `name=\"${String(attrs.name)}\" on a range: only the end thumb carries it, so the form receives one value of the two. Bind the model to two inputs of your own instead.`,\n )\n })\n}\n\n// @core\n// The text held by the number fields, kept apart from the slider's own value.\n//\n// It is typed as text OR a number because these are number fields, whose value Vue\n// converts to a number as soon as it can be read as one — while an empty field, or one\n// holding a half-typed \"1-\", stays text. That is why the value is turned back into text\n// when it is committed. The start field exists in range mode only, and so does its sync.\nconst startFieldText = ref<string | number>(String(startValue.value))\nconst endFieldText = ref<string | number>(String(endValue.value))\n\n// Sliding the thumb keeps the fields in step, continuously.\nwatch(startValue, (v) => {\n if (props.range) startFieldText.value = String(v)\n})\nwatch(endValue, (v) => (endFieldText.value = String(v)))\n\n// @core\n/**\n * Takes what was typed in a field and makes it the value — but only once the reader has\n * finished, on leaving the field or on Enter. Reading it as they type would clamp the\n * \"1\" of \"15\" to the minimum before the 5 was ever pressed.\n *\n * Anything unreadable, an empty field included, silently puts the previous value back.\n */\nfunction commitField(which: Thumb) {\n const raw = which === 'start' ? startFieldText.value : endFieldText.value\n // TRAP — parsed rather than converted: an empty string parses to nothing, which is\n // what triggers the revert below, where converting it would give ZERO and quietly\n // overwrite the value with it.\n const parsed = Number.parseFloat(String(raw))\n if (Number.isNaN(parsed)) {\n resyncFields()\n return\n }\n // Brought onto the nearest step. The rounding that follows removes the noise decimal\n // steps leave behind — a tenth cannot be represented exactly, so 0.1 × 3 comes out as\n // 0.30000000000000004.\n //\n // TRAP — the ceiling is the last step that FITS, never `max` itself. A native range\n // stops there (that is what `stepCount` counts), so clipped to a `max` the step does not\n // reach, the model held a value the thumb could not: the browser sanitized its input back\n // down, and the number, the thumb and the fill then said three different things. A step\n // that cannot move anything leaves the clamped value alone rather than dividing by it.\n const clamped = clamp(parsed, props.min, props.max)\n const snapped =\n props.step > 0\n ? props.min + Math.round((clamped - props.min) / props.step) * props.step\n : clamped\n const value = Math.min(lastStop.value, Math.round(snapped * 1e10) / 1e10)\n const previous = thumbValue(which)\n const next = writeThumb(which, value)\n // Emitted only when the value MOVED, as a native range emits nothing for a key that\n // cannot move its thumb — and the payload is `next`, the model's local copy lagging a\n // parent `v-model` (see `onThumbChange`).\n if (writtenFor(next, which) !== previous) emit('change', next)\n // Put back explicitly, because a commit that does not change the value — typing 200\n // where the maximum is 100 — changes nothing for the watchers to react to, and the\n // field would go on showing what was typed.\n resyncFields()\n}\n\nfunction resyncFields() {\n if (props.range) startFieldText.value = String(startValue.value)\n endFieldText.value = String(endValue.value)\n}\n\nconst endThumbEl = ref<HTMLInputElement | null>(null)\n\n// The root is a layout box, so a template ref on the component reaches the wrapper and not\n// the control. Both members point at the END thumb: it is the one always rendered, and the\n// one the consumer's `id` lands on, so `focus()` goes where a `<label for>` would send it.\ndefineExpose({\n /** Moves the focus to the end thumb, the only thumb outside range mode. */\n focus: (options?: FocusOptions) => endThumbEl.value?.focus(options),\n /** The end thumb's real `<input type=\"range\">`, for what `focus` does not cover. */\n el: endThumbEl,\n})\n</script>\n\n<template>\n <div\n class=\"v-slider\"\n :class=\"rootClass\"\n :data-range=\"range ? '' : undefined\"\n :data-disabled=\"resolvedDisabled ? '' : undefined\"\n :data-readonly=\"readonly ? '' : undefined\"\n :data-invalid=\"invalid ? '' : undefined\"\n :data-size=\"resolvedSize\"\n :data-orientation=\"orientation\"\n :style=\"[\n rootStyle,\n {\n '--slider-start-fraction': range ? String(frac(startValue)) : undefined,\n '--slider-end-fraction': String(frac(endValue)),\n },\n ]\"\n >\n <VInput\n v-if=\"inputs && range\"\n v-model=\"startFieldText\"\n class=\"v-slider-field v-slider-field-start\"\n type=\"number\"\n :size=\"resolvedSize\"\n :compact=\"resolvedCompact\"\n :min=\"min\"\n :max=\"max\"\n :step=\"step\"\n :disabled=\"resolvedDisabled\"\n :readonly=\"readonly\"\n :invalid=\"invalid\"\n :aria-label=\"startLabel\"\n @change=\"commitField('start')\"\n />\n <div class=\"v-slider-rail\">\n <span class=\"v-slider-control\">\n <span class=\"v-slider-track\" aria-hidden=\"true\">\n <span class=\"v-slider-fill\" />\n <span\n v-for=\"(tick, i) in tickPlaces\"\n :key=\"i\"\n class=\"v-slider-tick\"\n :data-filled=\"isFilled(tick.value) ? '' : undefined\"\n :style=\"tick.style\"\n />\n </span>\n <input\n v-if=\"range\"\n type=\"range\"\n class=\"v-slider-input v-slider-input-start\"\n :min=\"min\"\n :max=\"max\"\n :step=\"step\"\n :disabled=\"resolvedDisabled\"\n :value=\"startValue\"\n :aria-label=\"startLabel\"\n :aria-valuetext=\"startValueText\"\n :aria-invalid=\"invalid || undefined\"\n :aria-readonly=\"readonly || undefined\"\n @keydown=\"onThumbKeydown\"\n @input=\"onThumbInput('start', $event)\"\n @change=\"onThumbChange('start', $event)\"\n />\n <!-- The consumer's attributes come FIRST, so what the component decides for\n itself — the bounds, the value and the disabled state — cannot be\n overwritten by one of them, and so that an ARIA state the consumer sets is\n not erased by an `undefined` of ours. `aria-valuetext` is one of those: the\n component has one to say only when `labels` was given.\n\n The accessible NAME is the exception, and it is bound after on purpose: a\n range has two thumbs and needs a distinct word for each. It reads a name\n already resolved against these same attributes, so nothing is lost. -->\n <input\n ref=\"endThumbEl\"\n :aria-invalid=\"invalid || undefined\"\n :aria-readonly=\"readonly || undefined\"\n :aria-valuetext=\"endValueText\"\n v-bind=\"forwardedAttrs\"\n type=\"range\"\n class=\"v-slider-input v-slider-input-end\"\n :min=\"min\"\n :max=\"max\"\n :step=\"step\"\n :disabled=\"resolvedDisabled\"\n :value=\"endValue\"\n :aria-label=\"thumbEndLabel\"\n :aria-describedby=\"describedBy\"\n @keydown=\"onThumbKeydown\"\n @input=\"onThumbInput('end', $event)\"\n @change=\"onThumbChange('end', $event)\"\n />\n </span>\n <span\n v-if=\"tooltip && range\"\n class=\"v-slider-tooltip v-slider-tooltip-start\"\n aria-hidden=\"true\"\n >\n <span class=\"v-slider-tooltip-bubble\">{{ startValueText ?? startValue }}</span>\n </span>\n <span v-if=\"tooltip\" class=\"v-slider-tooltip v-slider-tooltip-end\" aria-hidden=\"true\">\n <span class=\"v-slider-tooltip-bubble\">{{ endValueText ?? endValue }}</span>\n </span>\n </div>\n <div v-if=\"labels\" class=\"v-slider-labels\">\n <span\n v-for=\"(item, i) in labels\"\n :key=\"i\"\n class=\"v-slider-label\"\n :style=\"stepPlaces[i]?.style\"\n >\n <template v-if=\"typeof item === 'string'\">{{ item }}</template>\n <VIcon v-else v-bind=\"iconProps(item.icon)\" :label=\"item.label\" />\n </span>\n </div>\n <VInput\n v-if=\"inputs\"\n v-model=\"endFieldText\"\n class=\"v-slider-field v-slider-field-end\"\n type=\"number\"\n :size=\"resolvedSize\"\n :compact=\"resolvedCompact\"\n :min=\"min\"\n :max=\"max\"\n :step=\"step\"\n :disabled=\"resolvedDisabled\"\n :readonly=\"readonly\"\n :invalid=\"invalid\"\n :aria-label=\"fieldEndLabel\"\n @change=\"commitField('end')\"\n />\n <span v-if=\"hint\" :id=\"hintId\" class=\"v-slider-hint\">{{ hint }}</span>\n </div>\n</template>\n\n<style>\n@layer vectis.components {\n .v-slider {\n --slider-thumb: var(--vectis-control-size-slider-thumb);\n --slider-track: var(--vectis-control-size-slider-track);\n display: grid;\n grid-template-areas: 'rail';\n grid-template-columns: minmax(0, 1fr);\n align-items: center;\n column-gap: var(--vectis-space-2);\n row-gap: var(--vectis-space-1);\n inline-size: 100%;\n font-family: var(--vectis-text-family);\n }\n\n /*\n The zones the root lays out, from least to most furnished. `:has()` takes the\n specificity of its argument, so the single-condition rules are all (0,2,0) and the\n two-condition ones (0,3,0) — which means the five below are arbitrated by SOURCE\n ORDER as much as by weight, and a range slider with inputs matches three of them at\n once. They must stay in this order, each configuration overwriting the poorer one it\n builds on. Alphabetize them, or move `field-start` above `field-end`, and the start\n field lands outside the grid: no error, just a control sitting where nothing put it. */\n .v-slider:has(.v-slider-labels) {\n grid-template-areas: 'rail' 'labels';\n }\n\n .v-slider:has(.v-slider-field-end) {\n grid-template-areas: 'rail field-end';\n grid-template-columns: minmax(0, 1fr) auto;\n }\n\n .v-slider:has(.v-slider-field-end):has(.v-slider-labels) {\n grid-template-areas: 'rail field-end' 'labels .';\n }\n\n .v-slider:has(.v-slider-field-start) {\n grid-template-areas: 'field-start rail field-end';\n grid-template-columns: auto minmax(0, 1fr) auto;\n }\n\n .v-slider:has(.v-slider-field-start):has(.v-slider-labels) {\n grid-template-areas: 'field-start rail field-end' '. labels .';\n }\n\n .v-slider-rail {\n grid-area: rail;\n position: relative;\n block-size: var(--slider-thumb);\n }\n\n .v-slider-control {\n position: absolute;\n inset: 0;\n }\n\n .v-slider-track {\n position: absolute;\n inset-inline: 0;\n inset-block: 0;\n margin-block: auto;\n block-size: var(--slider-track);\n border-radius: var(--vectis-radius-pill);\n background: var(--vectis-color-border);\n overflow: hidden;\n }\n\n /*\n Where a thumb's CENTRE sits for the fraction `--fill-fraction`, written once for every\n element lined up with it: half a thumb in, then the fraction of the run that is left.\n\n TRAP — it must be declared on the element that CARRIES the fraction, never on an\n ancestor. A custom property's `var()` is resolved where the property is declared, so\n set on the root it would read a `--fill-fraction` the root does not have and turn\n invalid, and every tick, label and bubble would drop to its static position at the\n start of the track, with no error. The `100%`, on the other hand, stays a percentage\n until it is used, and resolves against the box of the property that reads it — the\n inline size of the track for an inset, the block size in the vertical rules. */\n .v-slider-fill,\n .v-slider-tick,\n .v-slider-tooltip,\n .v-slider-label {\n --slider-at: calc(\n var(--slider-thumb) / 2 + (100% - var(--slider-thumb)) * var(--fill-fraction)\n );\n }\n\n .v-slider-fill {\n --fill-fraction: var(--slider-end-fraction);\n position: absolute;\n inset-block: 0;\n inset-inline-start: 0;\n inline-size: var(--slider-at);\n background: var(--vectis-color-accent);\n }\n\n .v-slider[data-range] .v-slider-fill {\n --fill-fraction: var(--slider-start-fraction);\n inset-inline-start: var(--slider-at);\n inline-size: calc(\n (100% - var(--slider-thumb)) * (var(--slider-end-fraction) - var(--slider-start-fraction))\n );\n }\n\n .v-slider-tick {\n --slider-tick: calc(var(--slider-track) / 3 * 2);\n position: absolute;\n inset-block: 0;\n margin-block: auto;\n inset-inline-start: calc(var(--slider-at) - var(--slider-tick) / 2);\n inline-size: var(--slider-tick);\n block-size: var(--slider-tick);\n border-radius: var(--vectis-radius-pill);\n background: var(--vectis-color-border-strong);\n }\n\n .v-slider-tick[data-filled] {\n background: var(--vectis-color-text-on-accent);\n }\n\n /* The two controls lie exactly on top of one another, so only their thumbs are allowed\n to receive the pointer. Without that, the one on top would swallow every click and\n the other thumb could never be grabbed.\n\n The thumb's paint lives in variables set HERE, on the control, and read once by each\n vendor pseudo-element below. That is what lets every state be written a single time:\n a state pseudo-class written after the thumb's own pseudo-element is unreliable across\n browsers, so a state has to be read on the control either way, and the thumb inherits\n the variables from it.\n\n The thumb is white in BOTH themes, the VSwitch thumb's colour: on a dark page a thumb\n painted in `surface` sinks into the track instead of standing out as the handle. */\n .v-slider-input {\n --slider-thumb-bg: var(--vectis-color-text-on-accent);\n --slider-thumb-border: var(--vectis-color-accent);\n --slider-thumb-shadow: var(--vectis-shadow-xs);\n --slider-thumb-outline: none;\n --slider-thumb-cursor: pointer;\n --slider-thumb-transition: background-color var(--vectis-duration-fast)\n var(--vectis-ease-default);\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n margin: 0;\n appearance: none;\n background: transparent;\n pointer-events: none;\n }\n\n /* With a single thumb there is nothing underneath to protect, so the whole control\n takes the pointer again — which is what makes clicking the track jump the value\n there and start dragging at once, natively and with no code at all. The accepted\n side effect is that the thumb lights up when the pointer is anywhere over the\n track. */\n .v-slider:not([data-range]) .v-slider-input {\n pointer-events: auto;\n cursor: pointer;\n }\n\n /* TRAP — the two vendor pseudo-elements stay in two separate rules. A selector list\n holding one a browser does not know is dropped WHOLE, so merged, each engine would\n throw away the other's thumb and draw its native one. */\n .v-slider-input::-webkit-slider-thumb {\n appearance: none;\n pointer-events: auto;\n width: var(--slider-thumb);\n height: var(--slider-thumb);\n border-radius: var(--vectis-radius-pill);\n background: var(--slider-thumb-bg);\n border: var(--vectis-control-border-width) solid var(--slider-thumb-border);\n box-shadow: var(--slider-thumb-shadow);\n outline: var(--slider-thumb-outline);\n outline-offset: var(--vectis-focus-ring-offset);\n cursor: var(--slider-thumb-cursor);\n transition: var(--slider-thumb-transition);\n }\n\n .v-slider-input::-moz-range-thumb {\n pointer-events: auto;\n width: var(--slider-thumb);\n height: var(--slider-thumb);\n border-radius: var(--vectis-radius-pill);\n background: var(--slider-thumb-bg);\n border: var(--vectis-control-border-width) solid var(--slider-thumb-border);\n box-shadow: var(--slider-thumb-shadow);\n outline: var(--slider-thumb-outline);\n outline-offset: var(--vectis-focus-ring-offset);\n cursor: var(--slider-thumb-cursor);\n transition: var(--slider-thumb-transition);\n }\n\n /* The states, all at (0,3,0) but the base, so their ORDER arbitrates them: hover, then\n read-only, invalid and disabled, each overriding what came before on a thumb that is\n several at once.\n\n The hover tint is mixed from the thumb's own white rather than read from\n `accent-surface`: that token turns dark in the dark theme, and a white thumb would\n flash nearly black under the pointer. */\n .v-slider-input:is(:hover, :active):not(:disabled) {\n --slider-thumb-bg: color-mix(\n in oklab,\n var(--vectis-color-text-on-accent),\n var(--vectis-color-accent) 10%\n );\n }\n\n .v-slider-input:focus-visible {\n --slider-thumb-outline: var(--vectis-focus-ring-width) solid var(--vectis-focus-ring-color);\n outline: none;\n }\n\n /* The muted text colour a read-only VCheckbox takes, on the fill and the thumb's ring:\n the value stays plainly readable, the accent no longer invites a drag. */\n .v-slider[data-readonly] .v-slider-input {\n --slider-thumb-bg: var(--vectis-color-surface);\n --slider-thumb-border: var(--vectis-color-text-muted);\n --slider-thumb-cursor: default;\n cursor: default;\n }\n\n .v-slider[data-readonly] .v-slider-fill {\n background: var(--vectis-color-text-muted);\n }\n\n .v-slider[data-readonly] .v-slider-tick[data-filled] {\n background: var(--vectis-color-surface);\n }\n\n /* The border colour VCheckbox and VRadio take when invalid, on the thumb's ring. */\n .v-slider[data-invalid] .v-slider-input {\n --slider-thumb-border: var(--vectis-color-danger);\n }\n\n /* The bubble showing the value. It looks like a VTooltip but is placed by arithmetic\n rather than anchored to the thumb: a native thumb is a pseudo-element, and the\n browser's anchoring cannot attach anything to one. The fraction it follows is the one\n the root already carries for the fill. */\n .v-slider-tooltip {\n position: absolute;\n inset-block-end: calc(100% + var(--vectis-space-2));\n inset-inline-start: var(--slider-at);\n inline-size: 0;\n display: flex;\n justify-content: center;\n opacity: 0;\n visibility: hidden;\n transition:\n opacity var(--vectis-duration-fast) var(--vectis-ease-default),\n visibility var(--vectis-duration-fast) var(--vectis-ease-default);\n }\n\n .v-slider-tooltip-start {\n --fill-fraction: var(--slider-start-fraction);\n }\n\n .v-slider-tooltip-end {\n --fill-fraction: var(--slider-end-fraction);\n }\n\n .v-slider:has(.v-slider-input-start:active) .v-slider-tooltip-start,\n .v-slider:has(.v-slider-input-start:focus-visible) .v-slider-tooltip-start,\n .v-slider:has(.v-slider-input-end:active) .v-slider-tooltip-end,\n .v-slider:has(.v-slider-input-end:focus-visible) .v-slider-tooltip-end {\n opacity: 1;\n visibility: visible;\n }\n\n .v-slider-tooltip-bubble {\n padding: var(--vectis-space-1) var(--vectis-space-2);\n background: var(--vectis-color-surface-inverse);\n color: var(--vectis-color-text-on-inverse);\n font-size: var(--vectis-text-caption-size);\n line-height: var(--vectis-text-caption-leading);\n /* VTooltip's bubble corner, so the two tooltips of the design system stay one shape. */\n border-radius: min(var(--vectis-radius-interactive), calc(0.5lh + var(--vectis-space-1)));\n box-shadow: var(--vectis-shadow-sm);\n white-space: nowrap;\n }\n\n .v-slider-labels {\n grid-area: labels;\n position: relative;\n /* The labels are positioned individually and therefore contribute no height of\n their own; this reserves enough room for either kind, a small line of text or an\n icon. */\n min-block-size: var(--vectis-icon-size-md);\n /* The pair travels together, as it does in every `[data-size]` block of\n control-size.css: the size applies to every source, the optical size only to the\n ligature, and setting one without the other draws a 20px glyph cut for 24. */\n --vectis-icon-size: var(--vectis-icon-size-md);\n --vectis-icon-opsz: 20;\n font-size: var(--vectis-text-caption-size);\n color: var(--vectis-color-text-muted);\n }\n\n /* A box of zero width placed exactly on the step: its content then overflows equally\n on both sides, which centres it there. Centring it with a transform would be the\n usual trick, but a transform is physical and would have to be undone for the\n vertical orientation and again for a right-to-left page. */\n .v-slider-label {\n position: absolute;\n inset-block-start: 0;\n inset-inline-start: var(--slider-at);\n inline-size: 0;\n display: flex;\n justify-content: center;\n white-space: nowrap;\n }\n\n /* The hint takes no area of its own: it spans every column and is AUTO-PLACED, which\n drops it into an implicit row under whichever of the eleven zone templates is in force.\n Given a row in each of them instead, the `row-gap` would open under every slider that\n has no hint at all. */\n .v-slider-hint {\n grid-column: 1 / -1;\n font-size: var(--vectis-text-caption-size);\n line-height: var(--vectis-text-caption-leading);\n color: var(--vectis-color-text-muted);\n }\n\n .v-slider[data-disabled] .v-slider-hint {\n color: var(--vectis-color-text-subtle);\n }\n\n .v-slider-field.v-input {\n inline-size: var(--vectis-control-size-slider-field);\n }\n\n /* Compounded with `.v-input` for the same reason as the width above: these land on an\n element VInput's own sheet styles, and at (0,1,0) they would TIE with `.v-input`. They\n set a `grid-area`, which VInput does not, so nothing is wrong today; the point is that\n one of the three rules on this element was hardened and two were not. */\n .v-slider-field-start.v-input {\n grid-area: field-start;\n }\n\n .v-slider-field-end.v-input {\n grid-area: field-end;\n }\n\n .v-slider[data-orientation='vertical'] {\n grid-template-areas: 'rail';\n grid-template-columns: none;\n inline-size: fit-content;\n justify-items: center;\n }\n\n .v-slider[data-orientation='vertical']:has(.v-slider-labels) {\n grid-template-areas: 'rail labels';\n grid-template-columns: auto auto;\n }\n\n .v-slider[data-orientation='vertical']:has(.v-slider-field-end) {\n grid-template-areas: 'field-end' 'rail';\n grid-template-columns: none;\n }\n\n .v-slider[data-orientation='vertical']:has(.v-slider-field-end):has(.v-slider-labels) {\n grid-template-areas: 'field-end .' 'rail labels';\n grid-template-columns: auto auto;\n }\n\n .v-slider[data-orientation='vertical']:has(.v-slider-field-start) {\n grid-template-areas: 'field-end' 'rail' 'field-start';\n grid-template-columns: none;\n }\n\n .v-slider[data-orientation='vertical']:has(.v-slider-field-start):has(.v-slider-labels) {\n grid-template-areas: 'field-end .' 'rail labels' 'field-start .';\n grid-template-columns: auto auto;\n }\n\n .v-slider[data-orientation='vertical'] .v-slider-rail {\n inline-size: var(--slider-thumb);\n block-size: var(--vectis-control-size-slider-length);\n }\n\n /* Only this inner box is turned upright, and that is the whole trick: the track, the\n fill and the ticks are all placed with logical properties, so they follow the change\n of axis with no rule written a second time — while the bubbles and the labels stay\n outside that box, and therefore keep their text horizontal.\n\n The direction is reversed with it so that the lowest value ends up at the bottom. */\n .v-slider[data-orientation='vertical'] .v-slider-control {\n writing-mode: vertical-lr;\n direction: rtl;\n }\n\n .v-slider[data-orientation='vertical'] .v-slider-tooltip {\n inset-block-end: var(--slider-at);\n inset-inline-start: auto;\n inset-inline-end: calc(100% + var(--vectis-space-2));\n inline-size: auto;\n block-size: 0;\n align-items: center;\n justify-content: flex-end;\n }\n\n .v-slider[data-orientation='vertical'] .v-slider-labels {\n align-self: stretch;\n min-block-size: auto;\n }\n\n .v-slider[data-orientation='vertical'] .v-slider-label {\n inset-block-start: auto;\n inset-block-end: var(--slider-at);\n inset-inline-start: 0;\n inline-size: auto;\n block-size: 0;\n align-items: center;\n justify-content: flex-start;\n }\n\n /* A disabled slider greys out through the colour tokens, the same ones VCheckbox and\n VSwitch use, and never through opacity. It comes LAST among the states, so a slider\n both disabled and read-only or invalid is drawn disabled. */\n .v-slider[data-disabled] {\n cursor: not-allowed;\n }\n\n .v-slider[data-disabled] .v-slider-track {\n background: var(--vectis-color-surface-muted);\n }\n\n .v-slider[data-disabled] .v-slider-fill {\n background: var(--vectis-color-text-subtle);\n }\n\n .v-slider[data-disabled] .v-slider-tick {\n background: var(--vectis-color-text-subtle);\n }\n\n /* A tick sitting on the greyed fill takes the light colour back, so that it stays\n visible against it — the same inversion VCheckbox applies to its disabled tick. */\n .v-slider[data-disabled] .v-slider-tick[data-filled] {\n background: var(--vectis-color-surface-muted);\n }\n\n .v-slider[data-disabled] .v-slider-labels {\n color: var(--vectis-color-text-subtle);\n }\n\n .v-slider[data-disabled] .v-slider-input {\n --slider-thumb-bg: var(--vectis-color-surface-muted);\n --slider-thumb-border: var(--vectis-color-text-subtle);\n --slider-thumb-shadow: none;\n --slider-thumb-cursor: not-allowed;\n cursor: not-allowed;\n }\n\n /* Windows forced colors erase every author colour, and the slider is painted almost\n entirely in backgrounds: the track, the fill and the ticks all flatten to `Canvas` and\n the bar disappears, leaving a thumb whose border is the only thing left on screen. The\n track draws its own edge as an inset ring here, the fill takes the system Highlight\n pair the rest of the design system uses for a selected state, and the ticks fall back\n to the text colour, inverted over the fill the way they already are over the accent.\n\n (0,4,0) is enough: nothing in this sheet reaches past (0,3,0). */\n @media (forced-colors: active) {\n .v-slider .v-slider-track {\n forced-color-adjust: none;\n background: Canvas;\n box-shadow: inset 0 0 0 var(--vectis-control-border-width) CanvasText;\n }\n\n .v-slider .v-slider-fill {\n forced-color-adjust: none;\n background: Highlight;\n }\n\n .v-slider .v-slider-tick {\n forced-color-adjust: none;\n background: CanvasText;\n }\n\n .v-slider .v-slider-tick[data-filled] {\n background: HighlightText;\n }\n\n .v-slider .v-slider-input {\n forced-color-adjust: none;\n --slider-thumb-bg: Canvas;\n --slider-thumb-border: CanvasText;\n --slider-thumb-shadow: none;\n }\n\n .v-slider[data-disabled] .v-slider-track {\n box-shadow: inset 0 0 0 var(--vectis-control-border-width) GrayText;\n }\n\n .v-slider[data-disabled] .v-slider-fill,\n .v-slider[data-disabled] .v-slider-tick {\n background: GrayText;\n }\n\n .v-slider[data-disabled] .v-slider-input {\n --slider-thumb-border: GrayText;\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .v-slider-input {\n --slider-thumb-transition: none;\n }\n\n .v-slider-tooltip {\n transition: none;\n }\n }\n}\n</style>\n"],"mappings":""}
1
+ {"version":3,"file":"VSlider.js","names":[],"sources":["../../../src/components/VSlider/VSlider.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @core\n/**\n * A value chosen by sliding a thumb along a track, optionally a range between two.\n *\n * Underneath is a native `<input type=\"range\">`, which brings the keyboard, the ARIA and the\n * form behaviour. The JS covers only what it cannot: keeping the pair of a range ordered —\n * there is no native two-thumb control, so a range is two superimposed — refusing a change\n * when `readonly`, which a range input has no native form of, feeding the optional number\n * fields, and computing the positions of the ticks and labels.\n *\n * Those positions reach the stylesheet as unitless inline fractions, and that is the whole\n * binding between the two. They matter because a thumb's CENTRE does not travel the full\n * width of the track: it runs from half a thumb in to half a thumb from the end, and\n * everything meant to line up with it has to follow that same run.\n */\n\nimport { computed, inject, reactive, ref, watch, watchEffect } from 'vue'\nimport VIcon from '../VIcon/VIcon.vue'\nimport { iconProps } from '../VIcon/iconProps'\nimport type { IconSource } from '../VIcon/types'\nimport VInput from '../VInput/VInput.vue'\nimport { inputGroupKey } from '../VInput/context'\n\nimport { useAriaLabel } from '../../composables/useAriaLabel'\nimport { useControlShape } from '../../composables/useControlShape'\nimport { useFieldIds } from '../../composables/useFieldIds'\nimport { useRootAttrs } from '../../composables/useRootAttrs'\nimport { isDev } from '../../utils/env'\nimport { clamp } from '../../utils/number'\nimport { useMessages } from '../../i18n/state'\n\n/**\n * What one step of the track is called: a piece of text, or an icon paired with the words\n * that name it, which is what a screen reader reads in place of the raw number.\n */\nexport type SliderLabel = string | { icon: IconSource; label: string }\n\n/** The value of a slider: one number, or an ordered pair of them in range mode. */\nexport type SliderValue = number | [number, number]\n\n/** Which way the track runs. */\nexport type SliderOrientation = 'horizontal' | 'vertical'\n\n/** The height of the number fields: 32, 40 or 48 pixels. */\nexport type SliderSize = 'sm' | 'md' | 'lg'\n\n/**\n * Where the number fields sit: none, at the ends of the track, or in a row above or below\n * it. On an upright slider, above and below become the two sides of it.\n */\nexport type SliderInputs = false | 'ends' | 'top' | 'bottom'\n\ninterface SliderProps {\n /** The lowest value the thumb can reach. It is 0 by default. */\n min?: number\n /** The highest value the thumb can reach. It is 100 by default. */\n max?: number\n /**\n * The gap between two values the thumb can stop on, 1 by default. It is also what the\n * arrow keys move by, and what a value typed into the companion field is snapped to.\n */\n step?: number\n /** Offers two thumbs to pick a range, which makes the v-model a pair of values. */\n range?: boolean\n /** Makes the slider unusable. */\n disabled?: boolean\n /**\n * Shows the value without allowing it to be changed. The thumbs can still be focused and\n * are announced as read-only, but neither the pointer nor the keyboard moves them, and the\n * number fields turn read-only with them.\n */\n readonly?: boolean\n /**\n * Marks the value as invalid, which colours the thumbs and tells assistive technology\n * so. It is for a rule the browser cannot check by itself.\n */\n invalid?: boolean\n /**\n * The height of the number fields `inputs` adds. Inside a VInputGroup the group's size\n * wins, as it does for every field of the row.\n */\n size?: SliderSize\n /**\n * What screen readers announce for the slider. It is an accessible name and draws\n * nothing on screen. In range mode the two thumbs are announced as the start and the\n * end of it.\n */\n label?: string\n /**\n * A line of help under the track, stating what the numbers mean or where they may go. It\n * is tied to the slider for assistive technology, so it is read out after the name rather\n * than as part of it.\n */\n hint?: string\n /** Turns the slider upright, with the lowest value at the bottom. */\n orientation?: SliderOrientation\n /**\n * Adds a number field for setting the value exactly: one, or one per end in range mode.\n * Sliding is quick but imprecise; this is the way out. `ends` puts the fields on either\n * side of the track, `top` and `bottom` in a row above or below it, each field at the\n * edge of the value it holds. An upright slider turns `top` and `bottom` into its start\n * and end sides. No field is drawn by default.\n */\n inputs?: SliderInputs\n /**\n * Marks each step on the track. Providing labels implies it. Past fifty steps the\n * marks would be an unreadable comb and are not drawn at all.\n */\n ticks?: boolean\n /**\n * A label for every step, in order: a piece of text, or an icon with the words that\n * name it for screen readers. They also become what a screen reader announces in\n * place of the raw number.\n */\n labels?: SliderLabel[]\n /** Shows the value in a bubble above the thumb while it is being moved or focused. */\n tooltip?: boolean\n}\n\nconst props = withDefaults(defineProps<SliderProps>(), {\n min: 0,\n max: 100,\n step: 1,\n range: false,\n disabled: false,\n readonly: false,\n invalid: false,\n size: 'md',\n label: undefined,\n hint: undefined,\n orientation: 'horizontal',\n inputs: false,\n ticks: false,\n labels: undefined,\n tooltip: false,\n})\n\n/**\n * The value: a single number, 0 to begin with, or an ordered pair once `range` is set. It\n * is that prop and not the shape of this value that decides how many thumbs are drawn.\n * The pair stays ordered whatever the reader does, a thumb taken past its sibling pushing\n * it along rather than stopping against it.\n */\nconst model = defineModel<SliderValue>({ default: 0 })\n\nconst emit = defineEmits<{\n /**\n * The value is BEING changed: every step of a drag, and every key that moves a thumb.\n * It carries the whole value, a pair in range mode, and fires for either thumb. It is\n * declared rather than left to fall through, because a range is two native controls and\n * an attribute only ever reaches one of them.\n */\n input: [value: SliderValue]\n /**\n * The reader has SETTLED on a value: a thumb was released or moved by a key, or a number\n * field was committed. It carries the whole value, a pair in range mode, and fires for\n * either thumb, where the v-model follows every step of a drag.\n */\n change: [value: SliderValue]\n}>()\n\n// The number fields are fields of a VInputGroup row like any other, so the row's size and\n// its disabled state reach them through the same arbitration. A slider has no `compact` of\n// its own: only the row's can apply.\nconst group = inject(inputGroupKey, null)\nconst {\n size: resolvedSize,\n compact: resolvedCompact,\n disabled: resolvedDisabled,\n} = useControlShape(\n {\n get size() {\n return props.size\n },\n compact: false,\n get disabled() {\n return props.disabled\n },\n },\n group,\n)\n\ntype Thumb = 'start' | 'end'\n\nconst startValue = computed(() => (Array.isArray(model.value) ? model.value[0] : props.min))\nconst endValue = computed(() =>\n Array.isArray(model.value) ? model.value[1] : (model.value as number),\n)\n\nconst thumbValue = (which: Thumb) => (which === 'start' ? startValue.value : endValue.value)\n\n/**\n * Where a value sits along the run, as a fraction between 0 and 1. The guard covers a\n * slider whose two bounds are equal, which would otherwise divide by zero.\n */\nconst frac = (v: number) => clamp((v - props.min) / (props.max - props.min || 1), 0, 1)\n\n// @core — the ONE place a thumb is written, so what keeps the pair ordered exists once for\n// the thumbs and the number fields alike. A range is two native controls laid over one\n// another, and there is no dual-thumb control to inherit this behaviour from. It returns\n// what was written, which is what each caller reads back: a model that lags a parent\n// `v-model` would still hold the value from before.\n//\n// TRAP — the thumb being held PUSHES its sibling; it must not stop against it. Two thumbs\n// resting on the same value are one thumb as far as the pointer is concerned, the end one\n// being last in the DOM and therefore the only one it can reach — so a range stopped at\n// `[100, 100]` could only ever be raised, and at the maximum it could not move at all. The\n// pair stays ordered either way; what changes is which thumb gives way.\nfunction writeThumb(which: Thumb, n: number): SliderValue {\n const next: SliderValue = !props.range\n ? n\n : which === 'start'\n ? [n, Math.max(n, endValue.value)]\n : [Math.min(n, startValue.value), n]\n model.value = next\n return next\n}\n\nconst writtenFor = (value: SliderValue, which: Thumb) =>\n Array.isArray(value) ? value[which === 'start' ? 0 : 1] : value\n\nfunction onThumbInput(which: Thumb, event: Event) {\n const el = event.target as HTMLInputElement\n // @core\n // A drag or a click on the track has ALREADY moved the native thumb when `input` fires,\n // and the event cannot be cancelled: putting the value back is the only refusal there is.\n // It lands before the browser paints, so the thumb never visibly leaves its place, and\n // since the value at release is the one it started from, no `change` follows either.\n if (props.readonly) {\n el.value = String(thumbValue(which))\n return\n }\n // Written back to the DOM as well: a thumb dragged past its sibling stops against it.\n const next = writeThumb(which, Number(el.value))\n el.value = String(writtenFor(next, which))\n emit('input', next)\n}\n\n// @keyboard @core\n// The keys that move a range input, cancelled before the browser applies them when the\n// slider is read-only. Tab and every other key pass, so the thumbs stay reachable.\nconst VALUE_KEYS = new Set([\n 'ArrowUp',\n 'ArrowDown',\n 'ArrowLeft',\n 'ArrowRight',\n 'Home',\n 'End',\n 'PageUp',\n 'PageDown',\n])\n\nfunction onThumbKeydown(event: KeyboardEvent) {\n if (props.readonly && VALUE_KEYS.has(event.key)) event.preventDefault()\n}\n\n// @core\n/*\n * A range is two native controls, and a consumer's `@change` could only ever reach ONE of\n * them through the forwarded attributes: moving the start thumb told nobody. So `change` is\n * declared and fed by both.\n *\n * TRAP — the settled value is read off the THUMB, never off the model. A key press fires\n * `input` and `change` back to back in one task, and under a parent `v-model` the model's\n * local copy only catches up when the parent re-renders, so it would still hold the value\n * from before the key.\n */\nfunction onThumbChange(which: Thumb, event: Event) {\n if (props.readonly) return\n const value = Number((event.target as HTMLInputElement).value)\n if (!props.range) emit('change', value)\n else if (which === 'start') emit('change', [value, Math.max(value, endValue.value)])\n else emit('change', [Math.min(value, startValue.value), value])\n}\n\n/**\n * How many whole steps the thumb can actually stop on. It matters when the range does\n * not divide evenly by the step: the native control stops at the last step that fits,\n * short of the maximum, and the ticks have to agree with it.\n */\nconst stepCount = computed(() =>\n props.step > 0 ? Math.floor((props.max - props.min) / props.step + 1e-9) : 0,\n)\n\n/**\n * The highest value a thumb can actually stop on, which is the last whole step and not\n * necessarily the maximum: with `min=0 max=95 step=10` the native control stops at 90.\n */\nconst lastStop = computed(() =>\n props.step > 0 ? props.min + stepCount.value * props.step : props.max,\n)\n\nconst showTicks = computed(\n () =>\n (props.ticks || props.labels !== undefined) && stepCount.value >= 1 && stepCount.value <= 50,\n)\n\n/**\n * Each step's value and the style that places it, shared by the ticks and the labels. Only\n * the bounds and the step move them, so dragging a thumb rebuilds none of it: each style\n * object keeps its identity from one render to the next, and Vue skips a binding whose\n * object has not changed. Which ticks are FILLED does follow the value, and is asked\n * separately, in `isFilled`.\n */\nconst stepPlaces = computed(() => {\n const length = Math.max(showTicks.value ? stepCount.value + 1 : 0, props.labels?.length ?? 0)\n return Array.from({ length }, (_, i) => {\n const value = props.min + i * props.step\n return { value, style: { '--fill-fraction': String(frac(value)) } }\n })\n})\n\nconst tickPlaces = computed(() =>\n showTicks.value ? stepPlaces.value.slice(0, stepCount.value + 1) : [],\n)\n\nconst isFilled = (value: number) =>\n props.range ? value >= startValue.value && value <= endValue.value : value <= endValue.value\n\n/** What a value is called, if the consumer named its step; failing that, the number itself. */\nfunction labelTextAt(value: number): string {\n const item = props.labels?.[Math.round((value - props.min) / props.step)]\n if (item === undefined) return String(value)\n return typeof item === 'string' ? item : item.label\n}\n\n// @a11y\n/* What each thumb is announced as. There is no prop for the two halves: the wording\n comes from the dictionary, which is also where it is changed.\n\n With a single thumb there is nothing to distinguish: that thumb IS the value, so it\n simply takes the resolved label, and is left unnamed when none was given. The\n generic fallback applies to the NUMBER FIELD alone, which cannot go unnamed: a bare\n field in a form has to say what it holds.\n\n TRAP — the name has to be RESOLVED against the consumer's attributes rather than read\n off the prop. The `:aria-label` below is bound AFTER the forwarded attributes, which is\n what lets a range give its two thumbs distinct names; bound from a bare `props.label`\n it ALSO overwrote an `aria-label` the consumer had written, with `undefined`, and\n `mergeProps` copies the key all the same. `useAriaLabel` is that resolution: it hands\n back the consumer's own name when there is one, and nothing at all under an\n `aria-labelledby`, which names the thumb by itself. */\nconst m = useMessages()\nconst resolvedLabel = useAriaLabel(() => props.label)\nconst startLabel = computed(() =>\n resolvedLabel.value ? m.value.slider.rangeStart(resolvedLabel.value) : m.value.slider.start,\n)\nconst endLabel = computed(() =>\n resolvedLabel.value ? m.value.slider.rangeEnd(resolvedLabel.value) : m.value.slider.end,\n)\nconst thumbEndLabel = computed(() => (props.range ? endLabel.value : resolvedLabel.value))\nconst fieldEndLabel = computed(() =>\n props.range ? endLabel.value : (resolvedLabel.value ?? m.value.slider.value),\n)\n\nconst startValueText = computed(() =>\n props.range && props.labels ? labelTextAt(startValue.value) : undefined,\n)\nconst endValueText = computed(() => (props.labels ? labelTextAt(endValue.value) : undefined))\n\n// @a11y\n/*\n * The wrapper-root split. The root here is a layout box holding the labels, the track and\n * the optional number fields, so a consumer's `name`, `id`, `required` and `aria-*` have\n * to be redirected onto the real `<input type=\"range\">` — left on the wrapper, a `name`\n * submits nothing and a `<label for>` points at a div.\n *\n * They go to the END thumb, which is the one always rendered. A RANGE has two thumbs and\n * therefore no single value to submit, which the warning below says out loud rather than\n * letting a form come back with half the answer.\n */\ndefineOptions({ inheritAttrs: false })\nconst { attrs, rootClass, rootStyle, forwardedAttrs } = useRootAttrs()\n\n// The hint is APPENDED to whatever the consumer already pointed at rather than replacing\n// it, which is why the binding below sits after the forwarded attributes, as on every\n// other field of the design system.\nconst { hintId, describedBy } = useFieldIds(attrs, () => !!props.hint)\n\n/**\n * Where the fields go, `ends` standing in for any other truthy value: a bare `inputs`\n * attribute reaches the component as `true`, the prop being part boolean.\n */\nconst inputsPlace = computed(() =>\n props.inputs === 'top' || props.inputs === 'bottom' ? props.inputs : props.inputs && 'ends',\n)\n\n// @devwarn\n/*\n * Every guard here describes something that fails SILENTLY. They sit in an effect, so a\n * bound that only becomes wrong on a later render is still caught, and each sentence is\n * given once per instance: the same warning repeated on every keystroke is how a warning\n * stops being read (the `useFileField` arrangement).\n */\nif (isDev) {\n // Keyed by a short id rather than by the sentence: each of these embeds a count, so the\n // same guard reported twice would read as two different messages and warn again.\n const warned = new Set<string>()\n const warn = (id: string, message: string) => {\n if (warned.has(id)) return\n warned.add(id)\n console.warn(`[VSlider] ${message}`)\n }\n watchEffect(() => {\n if (props.step <= 0)\n warn(\n 'step',\n `step=\"${props.step}\" cannot move a thumb: the arrow keys do nothing and a value typed into a field is committed as it stands. Give a positive step.`,\n )\n if ((props.ticks || props.labels) && stepCount.value > 50)\n warn(\n 'ticks',\n `${stepCount.value} steps: no tick is drawn past 50, the comb being unreadable.`,\n )\n if (props.labels && props.labels.length !== stepCount.value + 1)\n warn(\n 'labels',\n `${props.labels.length} labels for ${stepCount.value + 1} steps: one label per step expected.`,\n )\n if (props.inputs !== false && props.inputs !== inputsPlace.value)\n warn(\n 'inputs',\n `inputs=\"${String(props.inputs)}\" is not a placement: the fields are drawn at the ends. Write inputs=\"ends\", \"top\" or \"bottom\".`,\n )\n if (props.range && attrs.name !== undefined)\n warn(\n 'name',\n `name=\"${String(attrs.name)}\" on a range: only the end thumb carries it, so the form receives one value of the two. Bind the model to two inputs of your own instead.`,\n )\n })\n}\n\n// @core\n// The text held by the number fields, kept apart from the slider's own value.\n//\n// It is typed as text OR a number because these are number fields, whose value Vue\n// converts to a number as soon as it can be read as one — while an empty field, or one\n// holding a half-typed \"1-\", stays text. That is why the value is turned back into text\n// when it is committed. The start field exists in range mode only, and so does its sync.\nconst fieldText = reactive<Record<Thumb, string | number>>({\n start: String(startValue.value),\n end: String(endValue.value),\n})\n\n// Sliding the thumb keeps the fields in step, continuously.\nwatch(startValue, (v) => {\n if (props.range) fieldText.start = String(v)\n})\nwatch(endValue, (v) => (fieldText.end = String(v)))\n\n// @a11y\n/*\n * The fields are rendered in the order they are SEEN, before or after the track, so that\n * the tab order follows the screen. Laid out by the grid alone from a fixed DOM order, a\n * row of fields above the track would be reached one on each side of the thumbs.\n */\nconst fieldsBefore = computed<Thumb[]>(() => {\n if (inputsPlace.value === 'ends') return props.range ? ['start'] : []\n if (inputsPlace.value === 'top') return props.range ? ['start', 'end'] : ['end']\n return []\n})\nconst fieldsAfter = computed<Thumb[]>(() => {\n if (inputsPlace.value === 'ends') return ['end']\n if (inputsPlace.value === 'bottom') return props.range ? ['start', 'end'] : ['end']\n return []\n})\n\nconst fieldLabel = (which: Thumb) => (which === 'start' ? startLabel.value : fieldEndLabel.value)\n\n// @core\n/**\n * Takes what was typed in a field and makes it the value — but only once the reader has\n * finished, on leaving the field or on Enter. Reading it as they type would clamp the\n * \"1\" of \"15\" to the minimum before the 5 was ever pressed.\n *\n * Anything unreadable, an empty field included, silently puts the previous value back.\n */\nfunction commitField(which: Thumb) {\n const raw = fieldText[which]\n // TRAP — parsed rather than converted: an empty string parses to nothing, which is\n // what triggers the revert below, where converting it would give ZERO and quietly\n // overwrite the value with it.\n const parsed = Number.parseFloat(String(raw))\n if (Number.isNaN(parsed)) {\n resyncFields()\n return\n }\n // Brought onto the nearest step. The rounding that follows removes the noise decimal\n // steps leave behind — a tenth cannot be represented exactly, so 0.1 × 3 comes out as\n // 0.30000000000000004.\n //\n // TRAP — the ceiling is the last step that FITS, never `max` itself. A native range\n // stops there (that is what `stepCount` counts), so clipped to a `max` the step does not\n // reach, the model held a value the thumb could not: the browser sanitized its input back\n // down, and the number, the thumb and the fill then said three different things. A step\n // that cannot move anything leaves the clamped value alone rather than dividing by it.\n const clamped = clamp(parsed, props.min, props.max)\n const snapped =\n props.step > 0\n ? props.min + Math.round((clamped - props.min) / props.step) * props.step\n : clamped\n const value = Math.min(lastStop.value, Math.round(snapped * 1e10) / 1e10)\n const previous = thumbValue(which)\n const next = writeThumb(which, value)\n // Emitted only when the value MOVED, as a native range emits nothing for a key that\n // cannot move its thumb — and the payload is `next`, the model's local copy lagging a\n // parent `v-model` (see `onThumbChange`).\n if (writtenFor(next, which) !== previous) emit('change', next)\n // Put back explicitly, because a commit that does not change the value — typing 200\n // where the maximum is 100 — changes nothing for the watchers to react to, and the\n // field would go on showing what was typed.\n resyncFields()\n}\n\nfunction resyncFields() {\n if (props.range) fieldText.start = String(startValue.value)\n fieldText.end = String(endValue.value)\n}\n\nconst endThumbEl = ref<HTMLInputElement | null>(null)\n\n// The root is a layout box, so a template ref on the component reaches the wrapper and not\n// the control. Both members point at the END thumb: it is the one always rendered, and the\n// one the consumer's `id` lands on, so `focus()` goes where a `<label for>` would send it.\ndefineExpose({\n /** Moves the focus to the end thumb, the only thumb outside range mode. */\n focus: (options?: FocusOptions) => endThumbEl.value?.focus(options),\n /** The end thumb's real `<input type=\"range\">`, for what `focus` does not cover. */\n el: endThumbEl,\n})\n</script>\n\n<template>\n <div\n class=\"v-slider\"\n :class=\"rootClass\"\n :data-range=\"range ? '' : undefined\"\n :data-disabled=\"resolvedDisabled ? '' : undefined\"\n :data-readonly=\"readonly ? '' : undefined\"\n :data-invalid=\"invalid ? '' : undefined\"\n :data-size=\"resolvedSize\"\n :data-orientation=\"orientation\"\n :data-inputs=\"inputsPlace || undefined\"\n :style=\"[\n rootStyle,\n {\n '--slider-start-fraction': range ? String(frac(startValue)) : undefined,\n '--slider-end-fraction': String(frac(endValue)),\n },\n ]\"\n >\n <VInput\n v-for=\"which in fieldsBefore\"\n :key=\"which\"\n v-model=\"fieldText[which]\"\n :class=\"['v-slider-field', `v-slider-field-${which}`]\"\n type=\"number\"\n :size=\"resolvedSize\"\n :compact=\"resolvedCompact\"\n :min=\"min\"\n :max=\"max\"\n :step=\"step\"\n :disabled=\"resolvedDisabled\"\n :readonly=\"readonly\"\n :invalid=\"invalid\"\n :aria-label=\"fieldLabel(which)\"\n @change=\"commitField(which)\"\n />\n <div class=\"v-slider-rail\">\n <span class=\"v-slider-control\">\n <span class=\"v-slider-track\" aria-hidden=\"true\">\n <span class=\"v-slider-fill\" />\n <span\n v-for=\"(tick, i) in tickPlaces\"\n :key=\"i\"\n class=\"v-slider-tick\"\n :data-filled=\"isFilled(tick.value) ? '' : undefined\"\n :style=\"tick.style\"\n />\n </span>\n <input\n v-if=\"range\"\n type=\"range\"\n class=\"v-slider-input v-slider-input-start\"\n :min=\"min\"\n :max=\"max\"\n :step=\"step\"\n :disabled=\"resolvedDisabled\"\n :value=\"startValue\"\n :aria-label=\"startLabel\"\n :aria-valuetext=\"startValueText\"\n :aria-invalid=\"invalid || undefined\"\n :aria-readonly=\"readonly || undefined\"\n @keydown=\"onThumbKeydown\"\n @input=\"onThumbInput('start', $event)\"\n @change=\"onThumbChange('start', $event)\"\n />\n <!-- The consumer's attributes come FIRST, so what the component decides for\n itself — the bounds, the value and the disabled state — cannot be\n overwritten by one of them, and so that an ARIA state the consumer sets is\n not erased by an `undefined` of ours. `aria-valuetext` is one of those: the\n component has one to say only when `labels` was given.\n\n The accessible NAME is the exception, and it is bound after on purpose: a\n range has two thumbs and needs a distinct word for each. It reads a name\n already resolved against these same attributes, so nothing is lost. -->\n <input\n ref=\"endThumbEl\"\n :aria-invalid=\"invalid || undefined\"\n :aria-readonly=\"readonly || undefined\"\n :aria-valuetext=\"endValueText\"\n v-bind=\"forwardedAttrs\"\n type=\"range\"\n class=\"v-slider-input v-slider-input-end\"\n :min=\"min\"\n :max=\"max\"\n :step=\"step\"\n :disabled=\"resolvedDisabled\"\n :value=\"endValue\"\n :aria-label=\"thumbEndLabel\"\n :aria-describedby=\"describedBy\"\n @keydown=\"onThumbKeydown\"\n @input=\"onThumbInput('end', $event)\"\n @change=\"onThumbChange('end', $event)\"\n />\n </span>\n <span\n v-if=\"tooltip && range\"\n class=\"v-slider-tooltip v-slider-tooltip-start\"\n aria-hidden=\"true\"\n >\n <span class=\"v-slider-tooltip-bubble\">{{ startValueText ?? startValue }}</span>\n </span>\n <span v-if=\"tooltip\" class=\"v-slider-tooltip v-slider-tooltip-end\" aria-hidden=\"true\">\n <span class=\"v-slider-tooltip-bubble\">{{ endValueText ?? endValue }}</span>\n </span>\n </div>\n <div v-if=\"labels\" class=\"v-slider-labels\">\n <span\n v-for=\"(item, i) in labels\"\n :key=\"i\"\n class=\"v-slider-label\"\n :style=\"stepPlaces[i]?.style\"\n >\n <template v-if=\"typeof item === 'string'\">{{ item }}</template>\n <VIcon v-else v-bind=\"iconProps(item.icon)\" :label=\"item.label\" />\n </span>\n </div>\n <VInput\n v-for=\"which in fieldsAfter\"\n :key=\"which\"\n v-model=\"fieldText[which]\"\n :class=\"['v-slider-field', `v-slider-field-${which}`]\"\n type=\"number\"\n :size=\"resolvedSize\"\n :compact=\"resolvedCompact\"\n :min=\"min\"\n :max=\"max\"\n :step=\"step\"\n :disabled=\"resolvedDisabled\"\n :readonly=\"readonly\"\n :invalid=\"invalid\"\n :aria-label=\"fieldLabel(which)\"\n @change=\"commitField(which)\"\n />\n <span v-if=\"hint\" :id=\"hintId\" class=\"v-slider-hint\">{{ hint }}</span>\n </div>\n</template>\n\n<style>\n@layer vectis.components {\n .v-slider {\n --slider-thumb: var(--vectis-control-size-slider-thumb);\n --slider-track: var(--vectis-control-size-slider-track);\n display: grid;\n grid-template-areas: 'rail';\n grid-template-columns: minmax(0, 1fr);\n align-items: center;\n column-gap: var(--vectis-space-2);\n row-gap: var(--vectis-space-1);\n inline-size: 100%;\n font-family: var(--vectis-text-family);\n }\n\n /*\n The zones the root lays out, one template per combination of fields, range and labels.\n Each combination is written with exactly the conditions that define it, so a richer\n one always carries one condition more than the poorer ones it also matches and wins on\n WEIGHT, never on source order: `[data-inputs='ends'][data-range]:has(.v-slider-labels)`\n is (0,4,0) against the (0,3,0) of the two it builds on. `:has()` weighs its argument.\n The vertical block further down restates each case with `[data-orientation]` added,\n one condition more again, and must keep doing so: a vertical template left at the\n weight of its horizontal twin would be decided by order alone.\n\n `top` and `bottom` give the fields a row of their own, the start field at the start\n edge and the end field at the end one, above the values they hold. The empty middle\n column is what keeps the two apart; a single field leaves the first column empty. */\n .v-slider:has(.v-slider-labels) {\n grid-template-areas: 'rail' 'labels';\n }\n\n .v-slider[data-inputs='ends'] {\n grid-template-areas: 'rail field-end';\n grid-template-columns: minmax(0, 1fr) auto;\n }\n\n .v-slider[data-inputs='ends']:has(.v-slider-labels) {\n grid-template-areas: 'rail field-end' 'labels .';\n }\n\n .v-slider[data-inputs='ends'][data-range] {\n grid-template-areas: 'field-start rail field-end';\n grid-template-columns: auto minmax(0, 1fr) auto;\n }\n\n .v-slider[data-inputs='ends'][data-range]:has(.v-slider-labels) {\n grid-template-areas: 'field-start rail field-end' '. labels .';\n }\n\n .v-slider[data-inputs='top'] {\n grid-template-areas: 'field-start . field-end' 'rail rail rail';\n grid-template-columns: auto minmax(0, 1fr) auto;\n }\n\n .v-slider[data-inputs='top']:has(.v-slider-labels) {\n grid-template-areas: 'field-start . field-end' 'rail rail rail' 'labels labels labels';\n }\n\n .v-slider[data-inputs='bottom'] {\n grid-template-areas: 'rail rail rail' 'field-start . field-end';\n grid-template-columns: auto minmax(0, 1fr) auto;\n }\n\n .v-slider[data-inputs='bottom']:has(.v-slider-labels) {\n grid-template-areas: 'rail rail rail' 'labels labels labels' 'field-start . field-end';\n }\n\n .v-slider-rail {\n grid-area: rail;\n position: relative;\n block-size: var(--slider-thumb);\n }\n\n .v-slider-control {\n position: absolute;\n inset: 0;\n }\n\n .v-slider-track {\n position: absolute;\n inset-inline: 0;\n inset-block: 0;\n margin-block: auto;\n block-size: var(--slider-track);\n border-radius: var(--vectis-radius-pill);\n background: var(--vectis-color-border);\n overflow: hidden;\n }\n\n /*\n Where a thumb's CENTRE sits for the fraction `--fill-fraction`, written once for every\n element lined up with it: half a thumb in, then the fraction of the run that is left.\n\n TRAP — it must be declared on the element that CARRIES the fraction, never on an\n ancestor. A custom property's `var()` is resolved where the property is declared, so\n set on the root it would read a `--fill-fraction` the root does not have and turn\n invalid, and every tick, label and bubble would drop to its static position at the\n start of the track, with no error. The `100%`, on the other hand, stays a percentage\n until it is used, and resolves against the box of the property that reads it — the\n inline size of the track for an inset, the block size in the vertical rules. */\n .v-slider-fill,\n .v-slider-tick,\n .v-slider-tooltip,\n .v-slider-label {\n --slider-at: calc(\n var(--slider-thumb) / 2 + (100% - var(--slider-thumb)) * var(--fill-fraction)\n );\n }\n\n .v-slider-fill {\n --fill-fraction: var(--slider-end-fraction);\n position: absolute;\n inset-block: 0;\n inset-inline-start: 0;\n inline-size: var(--slider-at);\n background: var(--vectis-color-accent);\n }\n\n .v-slider[data-range] .v-slider-fill {\n --fill-fraction: var(--slider-start-fraction);\n inset-inline-start: var(--slider-at);\n inline-size: calc(\n (100% - var(--slider-thumb)) * (var(--slider-end-fraction) - var(--slider-start-fraction))\n );\n }\n\n .v-slider-tick {\n --slider-tick: calc(var(--slider-track) / 3 * 2);\n position: absolute;\n inset-block: 0;\n margin-block: auto;\n inset-inline-start: calc(var(--slider-at) - var(--slider-tick) / 2);\n inline-size: var(--slider-tick);\n block-size: var(--slider-tick);\n border-radius: var(--vectis-radius-pill);\n background: var(--vectis-color-border-strong);\n }\n\n .v-slider-tick[data-filled] {\n background: var(--vectis-color-text-on-accent);\n }\n\n /* The two controls lie exactly on top of one another, so only their thumbs are allowed\n to receive the pointer. Without that, the one on top would swallow every click and\n the other thumb could never be grabbed.\n\n The thumb's paint lives in variables set HERE, on the control, and read once by each\n vendor pseudo-element below. That is what lets every state be written a single time:\n a state pseudo-class written after the thumb's own pseudo-element is unreliable across\n browsers, so a state has to be read on the control either way, and the thumb inherits\n the variables from it.\n\n The thumb is white in BOTH themes, the VSwitch thumb's colour: on a dark page a thumb\n painted in `surface` sinks into the track instead of standing out as the handle. */\n .v-slider-input {\n --slider-thumb-bg: var(--vectis-color-text-on-accent);\n --slider-thumb-border: var(--vectis-color-accent);\n --slider-thumb-shadow: var(--vectis-shadow-xs);\n --slider-thumb-outline: none;\n --slider-thumb-cursor: pointer;\n --slider-thumb-transition: background-color var(--vectis-duration-fast)\n var(--vectis-ease-default);\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n margin: 0;\n appearance: none;\n background: transparent;\n pointer-events: none;\n }\n\n /* With a single thumb there is nothing underneath to protect, so the whole control\n takes the pointer again — which is what makes clicking the track jump the value\n there and start dragging at once, natively and with no code at all. The accepted\n side effect is that the thumb lights up when the pointer is anywhere over the\n track. */\n .v-slider:not([data-range]) .v-slider-input {\n pointer-events: auto;\n cursor: pointer;\n }\n\n /* TRAP — the two vendor pseudo-elements stay in two separate rules. A selector list\n holding one a browser does not know is dropped WHOLE, so merged, each engine would\n throw away the other's thumb and draw its native one. */\n .v-slider-input::-webkit-slider-thumb {\n appearance: none;\n pointer-events: auto;\n width: var(--slider-thumb);\n height: var(--slider-thumb);\n border-radius: var(--vectis-radius-pill);\n background: var(--slider-thumb-bg);\n border: var(--vectis-control-border-width) solid var(--slider-thumb-border);\n box-shadow: var(--slider-thumb-shadow);\n outline: var(--slider-thumb-outline);\n outline-offset: var(--vectis-focus-ring-offset);\n cursor: var(--slider-thumb-cursor);\n transition: var(--slider-thumb-transition);\n }\n\n .v-slider-input::-moz-range-thumb {\n pointer-events: auto;\n width: var(--slider-thumb);\n height: var(--slider-thumb);\n border-radius: var(--vectis-radius-pill);\n background: var(--slider-thumb-bg);\n border: var(--vectis-control-border-width) solid var(--slider-thumb-border);\n box-shadow: var(--slider-thumb-shadow);\n outline: var(--slider-thumb-outline);\n outline-offset: var(--vectis-focus-ring-offset);\n cursor: var(--slider-thumb-cursor);\n transition: var(--slider-thumb-transition);\n }\n\n /* The states, all at (0,3,0) but the base, so their ORDER arbitrates them: hover, then\n read-only, invalid and disabled, each overriding what came before on a thumb that is\n several at once.\n\n The hover tint is mixed from the thumb's own white rather than read from\n `accent-surface`: that token turns dark in the dark theme, and a white thumb would\n flash nearly black under the pointer. */\n .v-slider-input:is(:hover, :active):not(:disabled) {\n --slider-thumb-bg: color-mix(\n in oklab,\n var(--vectis-color-text-on-accent),\n var(--vectis-color-accent) 10%\n );\n }\n\n .v-slider-input:focus-visible {\n --slider-thumb-outline: var(--vectis-focus-ring-width) solid var(--vectis-focus-ring-color);\n outline: none;\n }\n\n /* The muted text colour a read-only VCheckbox takes, on the fill and the thumb's ring:\n the value stays plainly readable, the accent no longer invites a drag. */\n .v-slider[data-readonly] .v-slider-input {\n --slider-thumb-bg: var(--vectis-color-surface);\n --slider-thumb-border: var(--vectis-color-text-muted);\n --slider-thumb-cursor: default;\n cursor: default;\n }\n\n .v-slider[data-readonly] .v-slider-fill {\n background: var(--vectis-color-text-muted);\n }\n\n .v-slider[data-readonly] .v-slider-tick[data-filled] {\n background: var(--vectis-color-surface);\n }\n\n /* The border colour VCheckbox and VRadio take when invalid, on the thumb's ring. */\n .v-slider[data-invalid] .v-slider-input {\n --slider-thumb-border: var(--vectis-color-danger);\n }\n\n /* The bubble showing the value. It looks like a VTooltip but is placed by arithmetic\n rather than anchored to the thumb: a native thumb is a pseudo-element, and the\n browser's anchoring cannot attach anything to one. The fraction it follows is the one\n the root already carries for the fill. */\n .v-slider-tooltip {\n position: absolute;\n inset-block-end: calc(100% + var(--vectis-space-2));\n inset-inline-start: var(--slider-at);\n inline-size: 0;\n display: flex;\n justify-content: center;\n opacity: 0;\n visibility: hidden;\n transition:\n opacity var(--vectis-duration-fast) var(--vectis-ease-default),\n visibility var(--vectis-duration-fast) var(--vectis-ease-default);\n }\n\n .v-slider-tooltip-start {\n --fill-fraction: var(--slider-start-fraction);\n }\n\n .v-slider-tooltip-end {\n --fill-fraction: var(--slider-end-fraction);\n }\n\n .v-slider:has(.v-slider-input-start:active) .v-slider-tooltip-start,\n .v-slider:has(.v-slider-input-start:focus-visible) .v-slider-tooltip-start,\n .v-slider:has(.v-slider-input-end:active) .v-slider-tooltip-end,\n .v-slider:has(.v-slider-input-end:focus-visible) .v-slider-tooltip-end {\n opacity: 1;\n visibility: visible;\n }\n\n .v-slider-tooltip-bubble {\n padding: var(--vectis-space-1) var(--vectis-space-2);\n background: var(--vectis-color-surface-inverse);\n color: var(--vectis-color-text-on-inverse);\n font-size: var(--vectis-text-caption-size);\n line-height: var(--vectis-text-caption-leading);\n /* VTooltip's bubble corner, so the two tooltips of the design system stay one shape. */\n border-radius: min(var(--vectis-radius-interactive), calc(0.5lh + var(--vectis-space-1)));\n box-shadow: var(--vectis-shadow-sm);\n white-space: nowrap;\n }\n\n .v-slider-labels {\n grid-area: labels;\n position: relative;\n /* The labels are positioned individually and therefore contribute no height of\n their own; this reserves enough room for either kind, a small line of text or an\n icon. */\n min-block-size: var(--vectis-icon-size-md);\n /* The pair travels together, as it does in every `[data-size]` block of\n control-size.css: the size applies to every source, the optical size only to the\n ligature, and setting one without the other draws a 20px glyph cut for 24. */\n --vectis-icon-size: var(--vectis-icon-size-md);\n --vectis-icon-opsz: 20;\n font-size: var(--vectis-text-caption-size);\n color: var(--vectis-color-text-muted);\n }\n\n /* A box of zero width placed exactly on the step: its content then overflows equally\n on both sides, which centres it there. Centring it with a transform would be the\n usual trick, but a transform is physical and would have to be undone for the\n vertical orientation and again for a right-to-left page. */\n .v-slider-label {\n position: absolute;\n inset-block-start: 0;\n inset-inline-start: var(--slider-at);\n inline-size: 0;\n display: flex;\n justify-content: center;\n white-space: nowrap;\n }\n\n /* The hint takes no area of its own: it spans every column and is AUTO-PLACED, which\n drops it into an implicit row under whichever zone template is in force.\n Given a row in each of them instead, the `row-gap` would open under every slider that\n has no hint at all. */\n .v-slider-hint {\n grid-column: 1 / -1;\n font-size: var(--vectis-text-caption-size);\n line-height: var(--vectis-text-caption-leading);\n color: var(--vectis-color-text-muted);\n }\n\n .v-slider[data-disabled] .v-slider-hint {\n color: var(--vectis-color-text-subtle);\n }\n\n .v-slider-field.v-input {\n inline-size: var(--vectis-control-size-slider-field);\n }\n\n /* Compounded with `.v-input` for the same reason as the width above: these land on an\n element VInput's own sheet styles, and at (0,1,0) they would TIE with `.v-input`. They\n set a `grid-area`, which VInput does not, so nothing is wrong today; the point is that\n one of the three rules on this element was hardened and two were not. */\n .v-slider-field-start.v-input {\n grid-area: field-start;\n }\n\n .v-slider-field-end.v-input {\n grid-area: field-end;\n }\n\n .v-slider[data-orientation='vertical'] {\n grid-template-areas: 'rail';\n grid-template-columns: none;\n inline-size: fit-content;\n justify-items: center;\n }\n\n .v-slider[data-orientation='vertical']:has(.v-slider-labels) {\n grid-template-areas: 'rail labels';\n grid-template-columns: auto auto;\n }\n\n .v-slider[data-orientation='vertical'][data-inputs='ends'] {\n grid-template-areas: 'field-end' 'rail';\n grid-template-columns: none;\n }\n\n .v-slider[data-orientation='vertical'][data-inputs='ends']:has(.v-slider-labels) {\n grid-template-areas: 'field-end .' 'rail labels';\n grid-template-columns: auto auto;\n }\n\n .v-slider[data-orientation='vertical'][data-inputs='ends'][data-range] {\n grid-template-areas: 'field-end' 'rail' 'field-start';\n grid-template-columns: none;\n }\n\n .v-slider[data-orientation='vertical'][data-inputs='ends'][data-range]:has(.v-slider-labels) {\n grid-template-areas: 'field-end .' 'rail labels' 'field-start .';\n grid-template-columns: auto auto;\n }\n\n /* Upright, `top` and `bottom` become the two SIDES, the inline start and the inline end:\n the fields stack in a column beside the track, the end field level with its top and\n the start field with its bottom, where the values they hold sit. The track spans the\n three rows and gives them their height, the middle one taking whatever the fields\n leave. The labels stay against the track, so `bottom` puts the fields past them. */\n .v-slider[data-orientation='vertical'][data-inputs='top'] {\n grid-template-areas: 'field-end rail' '. rail' 'field-start rail';\n grid-template-columns: auto auto;\n grid-template-rows: auto 1fr auto;\n }\n\n .v-slider[data-orientation='vertical'][data-inputs='top']:has(.v-slider-labels) {\n grid-template-areas:\n 'field-end rail labels'\n '. rail labels'\n 'field-start rail labels';\n grid-template-columns: auto auto auto;\n }\n\n .v-slider[data-orientation='vertical'][data-inputs='bottom'] {\n grid-template-areas: 'rail field-end' 'rail .' 'rail field-start';\n grid-template-columns: auto auto;\n grid-template-rows: auto 1fr auto;\n }\n\n .v-slider[data-orientation='vertical'][data-inputs='bottom']:has(.v-slider-labels) {\n grid-template-areas:\n 'rail labels field-end'\n 'rail labels .'\n 'rail labels field-start';\n grid-template-columns: auto auto auto;\n }\n\n .v-slider[data-orientation='vertical'] .v-slider-rail {\n inline-size: var(--slider-thumb);\n block-size: var(--vectis-control-size-slider-length);\n }\n\n /* Only this inner box is turned upright, and that is the whole trick: the track, the\n fill and the ticks are all placed with logical properties, so they follow the change\n of axis with no rule written a second time — while the bubbles and the labels stay\n outside that box, and therefore keep their text horizontal.\n\n The direction is reversed with it so that the lowest value ends up at the bottom. */\n .v-slider[data-orientation='vertical'] .v-slider-control {\n writing-mode: vertical-lr;\n direction: rtl;\n }\n\n .v-slider[data-orientation='vertical'] .v-slider-tooltip {\n inset-block-end: var(--slider-at);\n inset-inline-start: auto;\n inset-inline-end: calc(100% + var(--vectis-space-2));\n inline-size: auto;\n block-size: 0;\n align-items: center;\n justify-content: flex-end;\n }\n\n .v-slider[data-orientation='vertical'] .v-slider-labels {\n align-self: stretch;\n min-block-size: auto;\n }\n\n .v-slider[data-orientation='vertical'] .v-slider-label {\n inset-block-start: auto;\n inset-block-end: var(--slider-at);\n inset-inline-start: 0;\n inline-size: auto;\n block-size: 0;\n align-items: center;\n justify-content: flex-start;\n }\n\n /* A disabled slider greys out through the colour tokens, the same ones VCheckbox and\n VSwitch use, and never through opacity. It comes LAST among the states, so a slider\n both disabled and read-only or invalid is drawn disabled. */\n .v-slider[data-disabled] {\n cursor: not-allowed;\n }\n\n .v-slider[data-disabled] .v-slider-track {\n background: var(--vectis-color-surface-muted);\n }\n\n .v-slider[data-disabled] .v-slider-fill {\n background: var(--vectis-color-text-subtle);\n }\n\n .v-slider[data-disabled] .v-slider-tick {\n background: var(--vectis-color-text-subtle);\n }\n\n /* A tick sitting on the greyed fill takes the light colour back, so that it stays\n visible against it — the same inversion VCheckbox applies to its disabled tick. */\n .v-slider[data-disabled] .v-slider-tick[data-filled] {\n background: var(--vectis-color-surface-muted);\n }\n\n .v-slider[data-disabled] .v-slider-labels {\n color: var(--vectis-color-text-subtle);\n }\n\n .v-slider[data-disabled] .v-slider-input {\n --slider-thumb-bg: var(--vectis-color-surface-muted);\n --slider-thumb-border: var(--vectis-color-text-subtle);\n --slider-thumb-shadow: none;\n --slider-thumb-cursor: not-allowed;\n cursor: not-allowed;\n }\n\n /* Windows forced colors erase every author colour, and the slider is painted almost\n entirely in backgrounds: the track, the fill and the ticks all flatten to `Canvas` and\n the bar disappears, leaving a thumb whose border is the only thing left on screen. The\n track draws its own edge as an inset ring here, the fill takes the system Highlight\n pair the rest of the design system uses for a selected state, and the ticks fall back\n to the text colour, inverted over the fill the way they already are over the accent.\n\n (0,4,0) is enough: nothing in this sheet reaches past (0,3,0). */\n @media (forced-colors: active) {\n .v-slider .v-slider-track {\n forced-color-adjust: none;\n background: Canvas;\n box-shadow: inset 0 0 0 var(--vectis-control-border-width) CanvasText;\n }\n\n .v-slider .v-slider-fill {\n forced-color-adjust: none;\n background: Highlight;\n }\n\n .v-slider .v-slider-tick {\n forced-color-adjust: none;\n background: CanvasText;\n }\n\n .v-slider .v-slider-tick[data-filled] {\n background: HighlightText;\n }\n\n .v-slider .v-slider-input {\n forced-color-adjust: none;\n --slider-thumb-bg: Canvas;\n --slider-thumb-border: CanvasText;\n --slider-thumb-shadow: none;\n }\n\n .v-slider[data-disabled] .v-slider-track {\n box-shadow: inset 0 0 0 var(--vectis-control-border-width) GrayText;\n }\n\n .v-slider[data-disabled] .v-slider-fill,\n .v-slider[data-disabled] .v-slider-tick {\n background: GrayText;\n }\n\n .v-slider[data-disabled] .v-slider-input {\n --slider-thumb-border: GrayText;\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .v-slider-input {\n --slider-thumb-transition: none;\n }\n\n .v-slider-tooltip {\n transition: none;\n }\n }\n}\n</style>\n"],"mappings":""}