vectis-ui 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/VColorPicker/VColorPicker.css +1 -1
- package/dist/components/VColorPicker/VColorPicker.js.map +1 -1
- package/dist/components/VColorPicker/VColorPicker.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/VCombobox/VCombobox.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/VCommandPalette/VCommandPalette.css +1 -1
- package/dist/components/VCommandPalette/VCommandPalette.js.map +1 -1
- package/dist/components/VCommandPalette/VCommandPalette.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/VDataTable/VDataTable.js.map +1 -1
- package/dist/components/VDataTable/VDataTable.vue_vue_type_script_setup_true_lang.js +6 -5
- package/dist/components/VDataTable/VDataTable.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/VNumberInput/number.d.ts +2 -2
- package/dist/components/VNumberInput/number.js +1 -1
- package/dist/components/VNumberInput/number.js.map +1 -1
- package/dist/components/VPagination/VPagination.js.map +1 -1
- package/dist/components/VPagination/VPagination.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/components/VPagination/VPagination.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/VSelect/VSelect.js.map +1 -1
- package/dist/components/VSelect/VSelect.vue_vue_type_script_setup_true_lang.js +168 -174
- package/dist/components/VSelect/VSelect.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/VTimeInput/VTimeInput.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/VTimePicker/VTimePicker.js.map +1 -1
- package/dist/components/VTimePicker/VTimePicker.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/components/VTimePicker/VTimePicker.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/VToggle/VToggle.js.map +1 -1
- package/dist/components/VToggle/VToggle.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/components/VToggle/VToggle.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/VTreeView/VTreeView.js.map +1 -1
- package/dist/components/VTreeView/VTreeView.vue_vue_type_script_setup_true_lang.js +127 -131
- package/dist/components/VTreeView/VTreeView.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/composables/useHotkeyListener.js +5 -2
- package/dist/composables/useHotkeyListener.js.map +1 -1
- package/dist/composables/useInfiniteScroll.js +2 -2
- package/dist/composables/useInfiniteScroll.js.map +1 -1
- package/dist/composables/useTypeahead.d.ts +13 -0
- package/dist/composables/useTypeahead.js +23 -0
- package/dist/composables/useTypeahead.js.map +1 -0
- package/dist/i18n/state.d.ts +1 -1
- package/dist/i18n/state.js +17 -6
- package/dist/i18n/state.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/utils/date.js +4 -3
- package/dist/utils/date.js.map +1 -1
- package/package.json +1 -1
|
@@ -271,7 +271,7 @@ var Ce = [
|
|
|
271
271
|
"onUpdate:modelValue": t[2] ||= (e) => q.value = e,
|
|
272
272
|
class: "v-time-picker-meridiem",
|
|
273
273
|
mandatory: "",
|
|
274
|
-
variant: "outline",
|
|
274
|
+
"item-variant": "outline",
|
|
275
275
|
"selected-variant": "soft",
|
|
276
276
|
orientation: "vertical",
|
|
277
277
|
size: "sm",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"VTimePicker.vue_vue_type_script_setup_true_lang.js","names":["$slots"],"sources":["../../../src/components/VTimePicker/VTimePicker.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @keyboard @a11y @core\n/**\n * CSS places clock numerals and the hand. JavaScript maps pointer angles and keyboard steps to\n * a time because HTML has no interactive clock-face primitive.\n */\n\nimport { computed, inject, ref, watchEffect } from 'vue'\n\nimport VButton from '../VButton/VButton.vue'\nimport VToggle from '../VToggle/VToggle.vue'\nimport type { ToggleModelValue } from '../VToggle/VToggle.vue'\nimport VToggleItem from '../VToggle/VToggleItem.vue'\nimport { formatTime, hourCycleFor, parseTime } from '../../utils/time'\nimport {\n DIAL_DEAD_ZONE,\n DIAL_INNER_THRESHOLD,\n angleToIndex,\n dialIndexToHour24,\n distanceFraction,\n hour24ToDial,\n hourWithMeridiem,\n snapMinute,\n to12h,\n to24h,\n} from '../../utils/clock'\nimport type { HourFormat, Meridiem } from '../../utils/time'\nimport {\n allowedMinutesFor,\n firstAllowed,\n isHourAllowed,\n isTimeAllowed,\n limitsProblem,\n minuteGrid,\n minuteInterval,\n nearestAllowedMinute,\n resolveLimits,\n} from './limits'\nimport type { TimePickerAllowed } from './limits'\nimport { pad2 } from '../../utils/text'\nimport { isDev } from '../../utils/env'\nimport { hostWarnsKey } from '../../utils/hostWarns'\nimport { useAriaLabel } from '../../composables/useAriaLabel'\nimport { useMessages, useResolvedLocale } from '../../i18n/state'\n\n/** Whether the face shows a 12- or a 24-hour clock. */\nexport type TimePickerFormat = HourFormat\n\n/** Which of the two halves of a time is being adjusted. */\ntype TimePickerStep = 'hour' | 'minute'\n\ninterface TimePickerProps {\n /**\n * Whether the face shows a 12- or a 24-hour clock. Left out, the reader's language\n * decides, which is almost always what one wants.\n */\n format?: TimePickerFormat\n /**\n * A BCP 47 locale, which decides the clock. It TAKES PRECEDENCE over the design\n * system's global locale and falls back to it, which is why it has no literal default:\n * `undefined` has to stay recognizable for the global locale to have its chance.\n */\n locale?: string\n /**\n * The interval the minutes snap to, both when dragging and with the arrow keys. The face\n * prints only the minutes it can reach, so a step of a quarter of an hour marks four.\n */\n minuteStep?: number\n /** The earliest time that can be chosen, inclusive, as a canonical 24-hour `'HH:mm'`. */\n min?: string\n /** The latest time that can be chosen, inclusive, written like `min`. */\n max?: string\n /** Which hours can be chosen: the list of them, or a rule answering for one. */\n allowedHours?: TimePickerAllowed\n /**\n * Which minutes can be chosen: the list of them, or a rule answering for one. The\n * minutes it leaves out are not printed.\n */\n allowedMinutes?: TimePickerAllowed\n /**\n * Makes the whole clock unusable: the hand cannot be moved, the half-day cannot be\n * changed, and everything greys out through the colour tokens.\n */\n disabled?: boolean\n /** Shows the time without letting it be changed. */\n readonly?: boolean\n /**\n * The accessible name of the whole clock, its two numerals and its face together. It falls\n * back to the dictionary, and a consumer `aria-label` wins over it.\n */\n label?: string\n}\n\nconst props = withDefaults(defineProps<TimePickerProps>(), {\n format: undefined,\n locale: undefined,\n minuteStep: 1,\n min: undefined,\n max: undefined,\n allowedHours: undefined,\n allowedMinutes: undefined,\n disabled: false,\n readonly: false,\n label: undefined,\n})\n\n/** The time, always as a 24-hour \"HH:mm\" string whatever clock is displayed. */\nconst model = defineModel<string | null>({ default: null })\n\nconst emit = defineEmits<{\n /**\n * The reader has FINISHED, carrying the time as it stands: the minutes were settled from the\n * keyboard.\n */\n confirm: [value: string | null]\n}>()\n\ndefineSlots<{\n /** A strip at the foot of the clock, the place for actions such as Cancel and OK. */\n footer?(): unknown\n}>()\n\nconst m = useMessages()\n// @a11y\n// A roleless box cannot carry an accessible name (axe: aria-prohibited-attr),\n// so the root is a named group: the VCarousel viewport arrangement.\nconst ariaLabel = useAriaLabel(() => props.label ?? m.value.timePicker.label)\nconst resolvedLocale = useResolvedLocale(() => props.locale)\nconst resolvedFormat = computed<TimePickerFormat>(\n () => props.format ?? hourCycleFor(resolvedLocale.value),\n)\n\n/** The restrictions, resolved once for the whole render. */\nconst limits = computed(() => resolveLimits(props))\n\n/** The hours the restrictions still leave something in, worked out once per set of restrictions. */\nconst availableHours = computed(() => {\n const hours = new Set<number>()\n for (let candidate = 0; candidate < 24; candidate += 1)\n if (isHourAllowed(candidate, limits.value)) hours.add(candidate)\n return hours\n})\n\n/** Whether an hour is one the restrictions still leave something in. */\nconst isAvailableHour = (candidate: number) => availableHours.value.has(candidate)\n\n// @devwarn\nif (isDev && !inject(hostWarnsKey, false)) {\n watchEffect(() => {\n if (!Number.isInteger(props.minuteStep) || props.minuteStep < 1 || 60 % props.minuteStep !== 0)\n console.warn(`[VTimePicker] minuteStep ${props.minuteStep} — a divisor of 60 is expected.`)\n const problem = limitsProblem(limits.value)\n if (problem) console.warn(`[VTimePicker] ${problem}`)\n })\n}\n\n/** Which of the two is being adjusted. It starts on the hour and moves on by itself. */\nconst step = ref<TimePickerStep>('hour')\n\n// With no value the clock shows the first hour of the half of the day chosen so far, which\n// is midnight until PM is picked. It is also the hour a time started from the MINUTES is\n// written with, so that choice is not lost when the reader begins there.\nconst parts = computed(\n () =>\n parseTime(model.value) ?? {\n hour: resolvedFormat.value === '12h' ? to24h(12, pendingMeridiem.value) : 0,\n minute: 0,\n },\n)\nconst hour = computed(() => parts.value.hour)\nconst minute = computed(() => parts.value.minute)\n\n/*\n * The two writers are the SINGLE cut-off point for the restrictions: the pointer, the six\n * keys and the AM/PM control all write through them and through nothing else, so not one\n * handler carries a guard of its own.\n */\n\n/**\n * Writing the hour. An hour with nothing left in it has no minute to fall back on, which\n * refuses it; one that is only PARTLY available pulls the minutes to what it does allow, since\n * `min: '09:30'` must not be left holding 09:00.\n */\nfunction setHour(value: number | null) {\n // Changing which face is shown stays allowed under `readonly`: reading the minutes is not\n // changing them.\n if (props.disabled || props.readonly) return\n if (value === null) return\n const minutes = isTimeAllowed(value, minute.value, limits.value)\n ? minute.value\n : nearestAllowedMinute(value, minute.value, limits.value)\n if (minutes === null) return\n model.value = formatTime(value, minutes)\n}\n\n/** Writing the minutes. Null is \"there was nowhere to go\", which every key can produce. */\nfunction setMinute(value: number | null) {\n if (props.disabled || props.readonly) return\n if (value === null || !isTimeAllowed(hour.value, value, limits.value)) return\n model.value = formatTime(hour.value, value)\n}\n\n// @a11y\n/*\n * Which step the clock is on is otherwise carried by the face's own name alone, and a change of\n * name is not reliably announced. A politely announced region says it a second time.\n */\nconst liveMessage = ref('')\n\nfunction setStep(next: TimePickerStep) {\n step.value = next\n liveMessage.value =\n next === 'minute' ? m.value.timePicker.choosingMinutes : m.value.timePicker.choosingHour\n}\n\n/** One numeral on the face. */\ninterface DialCell {\n key: string\n /** What is printed. */\n label: string\n /** Where it sits, as a fraction of a full turn from twelve o'clock. */\n turn: number\n /** Which of the two circles it belongs to, a 24-hour face having an inner one. */\n ring: 'outer' | 'inner'\n /** Whether it is the value currently being pointed at. */\n selected: boolean\n}\n\n/** The hour a position on the face stands for. */\nfunction hourAt(index: number, ring: 'outer' | 'inner'): number {\n return resolvedFormat.value === '24h'\n ? dialIndexToHour24(index, ring)\n : to24h(index === 0 ? 12 : index, currentMeridiem.value)\n}\n\n/** The minutes the face prints. */\nconst minuteMarks = computed(() => {\n const interval = minuteInterval(props.minuteStep)\n const spacing = 60 / interval <= 12 ? interval : 5\n const marks: number[] = []\n for (let minutes = 0; minutes < 60; minutes += spacing)\n if (minutes % interval === 0 && isTimeAllowed(hour.value, minutes, limits.value))\n marks.push(minutes)\n return marks\n})\n\n/*\n * A numeral that is only there to be refused says nothing the missing numeral does not say\n * better, and it says it in grey text a screen reader has no way to reach: the markers are\n * `aria-hidden`, so what the restrictions leave is spoken by the face's own value and by\n * nothing else.\n */\nconst cells = computed<DialCell[]>(() => {\n if (step.value === 'minute') {\n return minuteMarks.value.map((minutes) => ({\n key: `m-${minutes}`,\n label: pad2(minutes),\n turn: minutes / 60,\n ring: 'outer' as const,\n selected: minute.value === minutes,\n }))\n }\n // Each position's hour is worked out once, since on a 12-hour face that takes the half of the\n // day in force. The selection there still compares numerals and not hours: an empty clock\n // holding a PM choice sits on midnight, and its hand points at the 12 all the same.\n const twelve = resolvedFormat.value === '12h'\n const shown12 = to12h(hour.value).hour\n const rings: DialCell['ring'][] = twelve ? ['outer'] : ['outer', 'inner']\n const out: DialCell[] = []\n for (const ring of rings)\n for (let i = 0; i < 12; i += 1) {\n const target = hourAt(i, ring)\n if (!isAvailableHour(target)) continue\n const numeral = i === 0 ? 12 : i\n out.push({\n key: `${ring === 'outer' ? 'o' : 'i'}-${i}`,\n label: ring === 'inner' ? pad2(target) : String(numeral),\n turn: i / 12,\n ring,\n selected: twelve ? shown12 === numeral : hour.value === target,\n })\n }\n return out\n})\n\nconst handTurn = computed(() => {\n if (step.value === 'minute') return minute.value / 60\n const index =\n resolvedFormat.value === '24h' ? hour24ToDial(hour.value).index : to12h(hour.value).hour % 12\n return index / 12\n})\n\nconst handRing = computed(() =>\n step.value === 'hour' && resolvedFormat.value === '24h' ? hour24ToDial(hour.value).ring : 'outer',\n)\n\n/**\n * Whether the hand points at a minute that has no marker of its own. The tip is then\n * drawn small: at full size it would cover the two neighbouring markers and read as\n * pointing at neither.\n */\nconst handMinor = computed(\n () => step.value === 'minute' && !minuteMarks.value.includes(minute.value),\n)\n\n/** The hour as the two large numerals show it, on whichever clock is displayed. */\nconst displayHourText = computed(() =>\n pad2(resolvedFormat.value === '12h' ? to12h(hour.value).hour : hour.value),\n)\n\n/** Which half of the day is chosen while no time is set at all. */\nconst pendingMeridiem = ref<Meridiem>('AM')\n\n/**\n * The half of the day in force: the value's own once there is one, the remembered choice until\n * then.\n */\nconst currentMeridiem = computed<Meridiem>(() =>\n model.value ? to12h(hour.value).meridiem : pendingMeridiem.value,\n)\n\nconst meridiemModel = computed<ToggleModelValue>({\n get: () => currentMeridiem.value,\n set: (value) => {\n const meridiem: Meridiem = value === 'PM' ? 'PM' : 'AM'\n pendingMeridiem.value = meridiem\n // With no time set there is nothing to convert, so the choice is simply REMEMBERED\n // and applies to the first time chosen.\n if (!model.value) return\n // What is being chosen here is the HALF OF THE DAY, so the hour gives way to it: kept\n // when the new half allows it, and otherwise the first hour of that half that does.\n // Refusing the write instead would leave the control snapping straight back, since it\n // reads the half of the day off the value.\n const wanted = hourWithMeridiem(hour.value, meridiem)\n setHour(\n isAvailableHour(wanted)\n ? wanted\n : firstAllowed(12, (i) => to24h(i, meridiem), isAvailableHour),\n )\n },\n})\n\n/**\n * Whether each half of the day still holds an hour one could choose. An empty one takes its\n * button with it: a control that can only ever be refused is worse than no control.\n */\nconst meridiemAvailable = computed<Record<Meridiem, boolean>>(() => ({\n AM: firstAllowed(12, (i) => to24h(i, 'AM'), isAvailableHour) !== null,\n PM: firstAllowed(12, (i) => to24h(i, 'PM'), isAvailableHour) !== null,\n}))\n\n// @a11y\n// The entire spoken value of the face. The numerals are hidden from screen readers, so these\n// four attributes are the only thing assistive technology has: what the value is, what its\n// bounds are, and how to say it.\nconst ariaValueNow = computed(() => {\n if (step.value === 'minute') return minute.value\n return resolvedFormat.value === '12h' ? to12h(hour.value).hour : hour.value\n})\nconst ariaValueMin = computed(() =>\n step.value === 'minute' ? 0 : resolvedFormat.value === '12h' ? 1 : 0,\n)\nconst ariaValueMax = computed(() =>\n step.value === 'minute' ? 59 : resolvedFormat.value === '12h' ? 12 : 23,\n)\n// On a 12-hour face the half of the day is spoken with the hour: the AM/PM control is not\n// on the slider's way, and \"9 o'clock\" alone is two different times.\nconst ariaValueText = computed(() => {\n const m_ = m.value.timePicker\n if (step.value === 'minute') return m_.minutesValue(minute.value)\n const spoken = m_.hourValue(ariaValueNow.value)\n if (resolvedFormat.value === '24h') return spoken\n return `${spoken} ${currentMeridiem.value === 'PM' ? m_.pm : m_.am}`\n})\n\n/**\n * A step has been settled: after the hour come the minutes, and after the minutes the whole\n * thing is confirmed; but by KEYBOARD only, for the reason given on the emit.\n */\nfunction settleStep(via: 'pointer' | 'keyboard') {\n if (step.value === 'hour') setStep('minute')\n else if (via === 'keyboard') emit('confirm', model.value)\n}\n\nconst faceEl = ref<HTMLElement | null>(null)\nconst dragging = ref(false)\n/** Whether anything in the gesture under way landed on a numeral. */\nlet landed = false\n\n/**\n * Where a point on the face lands, and whether there was anything there at all. The write\n * itself still goes through `setHour`/ `setMinute` and their own guards, which is why\n * `readonly` is refused THERE and not here: a frozen clock is still one whose numerals are\n * real, and a tap on one moves the step on as it always did.\n */\nfunction applyPoint(event: PointerEvent): boolean {\n const face = faceEl.value\n // A disabled clock aims at nothing, so no step is moved on either: its header buttons\n // are disabled for the same reason.\n if (!face || props.disabled) return false\n // Measuring the face is safe here: this runs from a handler, hence in a browser, never\n // during a render.\n const rect = face.getBoundingClientRect()\n const dx = event.clientX - (rect.left + rect.width / 2)\n const dy = event.clientY - (rect.top + rect.height / 2)\n if (Math.hypot(dx, dy) <= DIAL_DEAD_ZONE * (rect.width / 2)) return false\n if (step.value === 'minute') {\n // The step's own snapping stands: a point between two markers has always been pulled\n // to the one it is nearest. What it is pulled to still has to BE on the face.\n const minutes = snapMinute(angleToIndex(dx, dy, 60), props.minuteStep)\n if (!isTimeAllowed(hour.value, minutes, limits.value)) return false\n setMinute(minutes)\n return true\n }\n const index = angleToIndex(dx, dy, 12)\n const ring =\n resolvedFormat.value === '24h' &&\n distanceFraction(dx, dy, rect.width / 2) < DIAL_INNER_THRESHOLD\n ? 'inner'\n : 'outer'\n const target = hourAt(index, ring)\n if (!isAvailableHour(target)) return false\n setHour(target)\n return true\n}\n\nfunction onPointerdown(event: PointerEvent) {\n // @fallback\n // Capture keeps dragging active outside the clock face; synthetic events may not reference a\n // capturable pointer.\n try {\n faceEl.value?.setPointerCapture(event.pointerId)\n } catch {\n /* Synthetic pointers cannot be captured. */\n }\n dragging.value = true\n landed = applyPoint(event)\n}\n\nfunction onPointermove(event: PointerEvent) {\n if (dragging.value && applyPoint(event)) landed = true\n}\n\nfunction onPointerup() {\n if (!dragging.value) return\n dragging.value = false\n // A gesture that never landed on a numeral has chosen nothing, so there is nothing to\n // move on FROM: the reader is left on the face they aimed at, looking at what they missed.\n if (landed) settleStep('pointer')\n}\n\nfunction onPointercancel() {\n dragging.value = false\n}\n\n// @keyboard @a11y\n// The keyboard a slider is expected to have.\n/*\n * Every key below walks until it finds something it MAY land on, rather than stopping at the\n * first thing it may not: with scattered hours a key that stopped would die at the first hole\n * and never reach what lies past it. Against a bound there is nothing past the hole, so the\n * same walk comes back empty and the key holds still, which a bound means.\n */\nfunction moveHour(delta: number) {\n if (resolvedFormat.value === '24h') {\n setHour(firstAllowed(23, (i) => (hour.value + delta * i + 24) % 24, isAvailableHour))\n return\n }\n // On a 12-hour face the hours cycle from 1 to 12 WITHIN the current half of the day:\n // passing midday is done on the AM/PM control, not by walking the hand past twelve.\n const hour12 = to12h(hour.value).hour\n setHour(\n firstAllowed(\n 11,\n (i) => to24h(((hour12 - 1 + delta * i + 12) % 12) + 1, currentMeridiem.value),\n isAvailableHour,\n ),\n )\n}\n\n/** The first or the last hour the face allows, on whichever clock it is showing. */\nfunction edgeHour(edge: 'first' | 'last'): number | null {\n if (resolvedFormat.value === '24h')\n return firstAllowed(24, (i) => (edge === 'first' ? i - 1 : 24 - i), isAvailableHour)\n return firstAllowed(\n 12,\n (i) => to24h(edge === 'first' ? i : 13 - i, currentMeridiem.value),\n isAvailableHour,\n )\n}\n\n/**\n * The minute a key lands on: the first step of the grid at least `distance` minutes away, that\n * way round, and then the next one the hour allows.\n */\nfunction minuteToward(direction: 1 | -1, distance: number): number | null {\n const grid = minuteGrid(props.minuteStep)\n const target = (((minute.value + direction * distance) % 60) + 60) % 60\n const n = grid.length\n let start = direction > 0 ? 0 : n - 1\n for (let k = 0; k < n; k += 1) {\n const i = direction > 0 ? k : n - 1 - k\n if (direction > 0 ? grid[i]! >= target : grid[i]! <= target) {\n start = i\n break\n }\n }\n return firstAllowed(\n n,\n (i) => grid[(((start + direction * (i - 1)) % n) + n) % n]!,\n (candidate) => isTimeAllowed(hour.value, candidate, limits.value),\n )\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n // The face of a disabled clock is out of the tab order, but `focus()` can still put the\n // focus there: nothing it is sent may move the step on or confirm.\n if (props.disabled) return\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n settleStep('keyboard')\n return\n }\n // The arrows are not flipped in a right-to-left page: a clock face is never mirrored;\n // clockwise means the same thing everywhere; so forward stays forward.\n const delta =\n event.key === 'ArrowUp' || event.key === 'ArrowRight'\n ? 1\n : event.key === 'ArrowDown' || event.key === 'ArrowLeft'\n ? -1\n : 0\n if (step.value === 'hour') {\n if (delta) moveHour(delta)\n else if (event.key === 'Home') setHour(edgeHour('first'))\n else if (event.key === 'End') setHour(edgeHour('last'))\n else return\n event.preventDefault()\n return\n }\n if (delta) setMinute(minuteToward(delta, 1))\n else if (event.key === 'PageUp') setMinute(minuteToward(1, 5))\n else if (event.key === 'PageDown') setMinute(minuteToward(-1, 5))\n // The list is built in these two branches alone: the arrows walk to a neighbour and never\n // need the whole of it.\n else if (event.key === 'Home') setMinute(allowedMinutesFor(hour.value, limits.value)[0] ?? null)\n else if (event.key === 'End')\n setMinute(allowedMinutesFor(hour.value, limits.value).at(-1) ?? null)\n else return\n event.preventDefault()\n}\n\ndefineExpose({\n /** Moves the focus onto the clock face, which the arrow keys drive. */\n focus: (options?: FocusOptions) => faceEl.value?.focus(options),\n /**\n * Puts the clock back on the hour and clears the announcement, WITHOUT announcing anything\n * itself.\n */\n reset: () => {\n step.value = 'hour'\n liveMessage.value = ''\n },\n})\n</script>\n\n<template>\n <div\n class=\"v-time-picker\"\n role=\"group\"\n :aria-label=\"ariaLabel\"\n :data-step=\"step\"\n :data-disabled=\"disabled ? '' : undefined\"\n :data-readonly=\"readonly ? '' : undefined\"\n >\n <!--\n The two large numerals switch between adjusting the hour and the minutes. The one being\n adjusted takes the accent tone, which on a quiet button shows as the colour of the numeral\n rather than as a filled background.\n -->\n <div class=\"v-time-picker-header\">\n <div class=\"v-time-picker-time\">\n <VButton\n class=\"v-time-picker-cell\"\n variant=\"ghost\"\n size=\"lg\"\n :tone=\"step === 'hour' ? 'accent' : 'neutral'\"\n :aria-pressed=\"step === 'hour' ? 'true' : 'false'\"\n :aria-label=\"`${displayHourText}, ${m.timePicker.selectHour}`\"\n :disabled=\"disabled\"\n @click=\"setStep('hour')\"\n >\n {{ displayHourText }}\n </VButton>\n <span class=\"v-time-picker-sep\" aria-hidden=\"true\">:</span>\n <VButton\n class=\"v-time-picker-cell\"\n variant=\"ghost\"\n size=\"lg\"\n :tone=\"step === 'minute' ? 'accent' : 'neutral'\"\n :aria-pressed=\"step === 'minute' ? 'true' : 'false'\"\n :aria-label=\"`${pad2(minute)}, ${m.timePicker.selectMinutes}`\"\n :disabled=\"disabled\"\n @click=\"setStep('minute')\"\n >\n {{ pad2(minute) }}\n </VButton>\n </div>\n\n <VToggle\n v-if=\"resolvedFormat === '12h'\"\n v-model=\"meridiemModel\"\n class=\"v-time-picker-meridiem\"\n mandatory\n variant=\"outline\"\n selected-variant=\"soft\"\n orientation=\"vertical\"\n size=\"sm\"\n :label=\"m.timePicker.meridiem\"\n :disabled=\"disabled || readonly\"\n >\n <VToggleItem value=\"AM\" :label=\"m.timePicker.am\" :disabled=\"!meridiemAvailable.AM\" />\n <VToggleItem value=\"PM\" :label=\"m.timePicker.pm\" :disabled=\"!meridiemAvailable.PM\" />\n </VToggle>\n </div>\n\n <div\n ref=\"faceEl\"\n role=\"slider\"\n :tabindex=\"disabled ? -1 : 0\"\n class=\"v-time-picker-face\"\n :aria-label=\"step === 'hour' ? m.timePicker.hour : m.timePicker.minutes\"\n :aria-valuemin=\"ariaValueMin\"\n :aria-valuemax=\"ariaValueMax\"\n :aria-valuenow=\"ariaValueNow\"\n :aria-valuetext=\"ariaValueText\"\n :data-dragging=\"dragging ? '' : undefined\"\n :aria-disabled=\"disabled ? 'true' : undefined\"\n :aria-readonly=\"readonly ? 'true' : undefined\"\n @pointerdown=\"onPointerdown\"\n @pointermove=\"onPointermove\"\n @pointerup=\"onPointerup\"\n @pointercancel=\"onPointercancel\"\n @keydown=\"onKeydown\"\n >\n <div\n class=\"v-time-picker-hand\"\n aria-hidden=\"true\"\n :data-ring=\"handRing\"\n :data-minor=\"handMinor ? '' : undefined\"\n :style=\"{ '--dial-turn': String(handTurn) }\"\n />\n <span class=\"v-time-picker-center\" aria-hidden=\"true\" />\n <span\n v-for=\"cell in cells\"\n :key=\"cell.key\"\n class=\"v-time-picker-number\"\n aria-hidden=\"true\"\n :data-ring=\"cell.ring\"\n :data-selected=\"cell.selected ? '' : undefined\"\n :style=\"{ '--dial-turn': String(cell.turn) }\"\n >{{ cell.label }}</span\n >\n </div>\n\n <div class=\"v-visually-hidden\" aria-live=\"polite\">{{ liveMessage }}</div>\n\n <div v-if=\"$slots.footer\" class=\"v-time-picker-footer\"><slot name=\"footer\" /></div>\n </div>\n</template>\n\n<style>\n@layer vectis.components {\n /* The same root as VDatePicker's: an inline column that sizes to its content and pads\n itself, so the clock reads the same on a page as in VTimeInput's panel, which adds no\n padding of its own. The gap is the clock's, its parts being larger. */\n .v-time-picker {\n display: inline-flex;\n flex-direction: column;\n gap: var(--vectis-space-4);\n padding: var(--vectis-space-3);\n font-family: var(--vectis-text-family);\n color: var(--vectis-color-text);\n }\n\n /*\n * The numerals and the choice of half-day on one row, the second beside the first. The gap is\n * logical, so the control follows the reading direction; the numerals inside it stay in a\n * group of their own, which keeps the direction they force off it.\n */\n .v-time-picker-header {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: var(--vectis-space-3);\n }\n\n /* A time written in figures always reads hours then minutes, in every language. Forcing\n the direction here is what stops bidirectional reordering from swapping the two in a\n right-to-left page. */\n .v-time-picker-time {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: var(--vectis-space-2);\n direction: ltr;\n }\n\n /*\n * What is overridden here is the \"large numeral\" look alone; the width and the type; the\n * height, the states, the focus ring and the transitions all come from the button itself. The\n * type is the `heading-1` recipe taken whole, and the colon the `heading-2` one: read in\n * halves, a consumer repointing `--vectis-text-heading-1-weight` would move every heading but\n * these two numerals.\n */\n .v-time-picker-cell[data-size] {\n width: var(--control-height);\n font-size: var(--vectis-text-heading-1-size);\n font-weight: var(--vectis-text-heading-1-weight);\n line-height: var(--vectis-text-heading-1-leading);\n letter-spacing: var(--vectis-text-heading-1-tracking);\n /* Figures of equal width, so that going from \"11\" to \"00\" does not shift the cell. */\n font-variant-numeric: tabular-nums;\n }\n\n .v-time-picker-sep {\n font-size: var(--vectis-text-heading-2-size);\n font-weight: var(--vectis-text-heading-2-weight);\n line-height: var(--vectis-text-heading-2-leading);\n color: var(--vectis-color-text);\n user-select: none;\n }\n\n .v-time-picker-meridiem {\n flex: none;\n }\n\n .v-time-picker-face {\n position: relative;\n /*\n * The row above is wider than the face as soon as the half-day control is rendered, and the\n * column is only as wide as its widest child: without this the clock would hang at the\n * start edge of that row instead of under the middle of it. The face itself stays physical;\n * a clock is never mirrored.\n */\n align-self: center;\n inline-size: var(--vectis-control-size-time-picker-dial);\n block-size: var(--vectis-control-size-time-picker-dial);\n border-radius: var(--vectis-radius-pill);\n background: var(--vectis-color-surface-muted);\n /* Dragging across the face IS how one chooses, so it must do nothing else: no\n scrolling under a finger, and no selecting the numerals as text. */\n touch-action: none;\n user-select: none;\n cursor: pointer;\n /*\n * This is coupled to the threshold that decides which of the two circles a point belongs\n * to, in `utils/clock`. Changing one without the other makes the face answer with the wrong\n * ring, and no test can catch it: the unit tests measure nothing.\n */\n --dial-radius: calc(\n var(--vectis-control-size-time-picker-dial) / 2 -\n var(--vectis-control-size-time-picker-number) / 2\n );\n }\n\n .v-time-picker-face:focus-visible {\n outline: var(--vectis-focus-ring-width) solid var(--vectis-focus-ring-color);\n outline-offset: var(--vectis-focus-ring-offset);\n }\n\n /*\n * The properties used are PHYSICAL on purpose, where the rest of the design system prefers\n * logical ones: a clock face is never mirrored, clockwise meaning the same thing in every\n * language.\n */\n .v-time-picker-number {\n position: absolute;\n left: calc(50% + var(--dial-radius) * sin(var(--dial-turn) * 1turn));\n top: calc(50% - var(--dial-radius) * cos(var(--dial-turn) * 1turn));\n translate: -50% -50%;\n inline-size: var(--vectis-control-size-time-picker-number);\n block-size: var(--vectis-control-size-time-picker-number);\n display: grid;\n place-items: center;\n border-radius: var(--vectis-radius-pill);\n /* The outer circle is set at the large body size: these numerals are read at arm's\n length on a phone, not scanned like a label. */\n font-size: var(--vectis-text-body-lg-size);\n color: var(--vectis-color-text);\n /*\n * Above the hand, so that the numeral being pointed at reads on its dot rather than being\n * covered by it.\n */\n z-index: 1;\n pointer-events: none;\n }\n\n .v-time-picker-number[data-ring='inner'],\n .v-time-picker-hand[data-ring='inner'] {\n --dial-radius: calc(\n var(--vectis-control-size-time-picker-dial) / 2 -\n var(--vectis-control-size-time-picker-number) * 1.5\n );\n }\n\n .v-time-picker-number[data-ring='inner'] {\n font-size: var(--vectis-text-body-md-size);\n color: var(--vectis-color-text-muted);\n }\n\n .v-time-picker-number[data-selected] {\n color: var(--vectis-color-text-on-accent);\n }\n\n /*\n * The rotation is deliberately not animated. Going from 55 minutes to 0 takes the angle from\n * nearly a full turn back to none, and an interpolation would sweep the hand all the way\n * round anticlockwise.\n */\n .v-time-picker-hand {\n position: absolute;\n left: calc(50% - var(--vectis-control-size-time-picker-hand) / 2);\n bottom: 50%;\n inline-size: var(--vectis-control-size-time-picker-hand);\n block-size: var(--dial-radius);\n background: var(--vectis-color-accent);\n transform-origin: 50% 100%;\n rotate: calc(var(--dial-turn) * 1turn);\n }\n\n /*\n * The dot at the tip of the hand. It is exactly the size of a numeral's cell, so it covers\n * the one being pointed at; whose text turns to the colour that reads on it.\n */\n .v-time-picker-hand::before {\n content: '';\n position: absolute;\n top: 0;\n left: 50%;\n translate: -50% -50%;\n inline-size: var(--vectis-control-size-time-picker-number);\n block-size: var(--vectis-control-size-time-picker-number);\n border-radius: var(--vectis-radius-pill);\n background: var(--vectis-color-accent);\n /* The ONE thing animated on the hand: the dot changing size between its two forms. It\n interpolates between two bounded values and can therefore never take a wrong path,\n unlike the rotation above. */\n transition:\n inline-size var(--vectis-duration-fast) var(--vectis-ease-default),\n block-size var(--vectis-duration-fast) var(--vectis-ease-default);\n }\n\n /*\n * Only the DOT's own size changes. Redefining the numeral size variable here would also move\n * the radius derived from it, and with it the hand's length and the position of both circles.\n */\n .v-time-picker-hand[data-minor]::before {\n inline-size: var(--vectis-control-size-time-picker-hand-minor);\n block-size: var(--vectis-control-size-time-picker-hand-minor);\n }\n\n .v-time-picker-center {\n position: absolute;\n left: 50%;\n top: 50%;\n translate: -50% -50%;\n inline-size: var(--vectis-control-size-time-picker-center);\n block-size: var(--vectis-control-size-time-picker-center);\n border-radius: var(--vectis-radius-pill);\n background: var(--vectis-color-accent);\n }\n\n /* Neither state is a pointer target. A read-only clock is still read, so it keeps the\n ordinary cursor; a disabled one refuses. */\n .v-time-picker[data-readonly] .v-time-picker-face {\n cursor: default;\n }\n\n .v-time-picker[data-disabled] .v-time-picker-face {\n cursor: not-allowed;\n }\n\n /*\n * The whole block sits after the rules it overrides, every one of them at the same\n * specificity: this is one sheet, so its own order is what settles them.\n */\n .v-time-picker[data-disabled] .v-time-picker-hand,\n .v-time-picker[data-disabled] .v-time-picker-hand::before,\n .v-time-picker[data-disabled] .v-time-picker-center {\n background: var(--vectis-color-border-strong);\n }\n\n .v-time-picker[data-disabled] .v-time-picker-sep,\n .v-time-picker[data-disabled] .v-time-picker-number,\n .v-time-picker[data-disabled] .v-time-picker-number[data-selected] {\n color: var(--vectis-color-text-subtle);\n }\n\n .v-time-picker-footer {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: var(--vectis-space-2);\n padding-block-start: var(--vectis-space-2);\n border-block-start: 1px solid var(--vectis-color-border);\n }\n\n @media (prefers-reduced-motion: reduce) {\n .v-time-picker-hand::before {\n transition: none;\n }\n }\n}\n</style>\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"VTimePicker.vue_vue_type_script_setup_true_lang.js","names":["$slots"],"sources":["../../../src/components/VTimePicker/VTimePicker.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @keyboard @a11y @core\n/**\n * CSS places clock numerals and the hand. JavaScript maps pointer angles and keyboard steps to\n * a time because HTML has no interactive clock-face primitive.\n */\n\nimport { computed, inject, ref, watchEffect } from 'vue'\n\nimport VButton from '../VButton/VButton.vue'\nimport VToggle from '../VToggle/VToggle.vue'\nimport type { ToggleModelValue } from '../VToggle/VToggle.vue'\nimport VToggleItem from '../VToggle/VToggleItem.vue'\nimport { formatTime, hourCycleFor, parseTime } from '../../utils/time'\nimport {\n DIAL_DEAD_ZONE,\n DIAL_INNER_THRESHOLD,\n angleToIndex,\n dialIndexToHour24,\n distanceFraction,\n hour24ToDial,\n hourWithMeridiem,\n snapMinute,\n to12h,\n to24h,\n} from '../../utils/clock'\nimport type { HourFormat, Meridiem } from '../../utils/time'\nimport {\n allowedMinutesFor,\n firstAllowed,\n isHourAllowed,\n isTimeAllowed,\n limitsProblem,\n minuteGrid,\n minuteInterval,\n nearestAllowedMinute,\n resolveLimits,\n} from './limits'\nimport type { TimePickerAllowed } from './limits'\nimport { pad2 } from '../../utils/text'\nimport { isDev } from '../../utils/env'\nimport { hostWarnsKey } from '../../utils/hostWarns'\nimport { useAriaLabel } from '../../composables/useAriaLabel'\nimport { useMessages, useResolvedLocale } from '../../i18n/state'\n\n/** Whether the face shows a 12- or a 24-hour clock. */\nexport type TimePickerFormat = HourFormat\n\n/** Which of the two halves of a time is being adjusted. */\ntype TimePickerStep = 'hour' | 'minute'\n\ninterface TimePickerProps {\n /**\n * Whether the face shows a 12- or a 24-hour clock. Left out, the reader's language\n * decides, which is almost always what one wants.\n */\n format?: TimePickerFormat\n /**\n * A BCP 47 locale, which decides the clock. It TAKES PRECEDENCE over the design\n * system's global locale and falls back to it, which is why it has no literal default:\n * `undefined` has to stay recognizable for the global locale to have its chance.\n */\n locale?: string\n /**\n * The interval the minutes snap to, both when dragging and with the arrow keys. The face\n * prints only the minutes it can reach, so a step of a quarter of an hour marks four.\n */\n minuteStep?: number\n /** The earliest time that can be chosen, inclusive, as a canonical 24-hour `'HH:mm'`. */\n min?: string\n /** The latest time that can be chosen, inclusive, written like `min`. */\n max?: string\n /** Which hours can be chosen: the list of them, or a rule answering for one. */\n allowedHours?: TimePickerAllowed\n /**\n * Which minutes can be chosen: the list of them, or a rule answering for one. The\n * minutes it leaves out are not printed.\n */\n allowedMinutes?: TimePickerAllowed\n /**\n * Makes the whole clock unusable: the hand cannot be moved, the half-day cannot be\n * changed, and everything greys out through the colour tokens.\n */\n disabled?: boolean\n /** Shows the time without letting it be changed. */\n readonly?: boolean\n /**\n * The accessible name of the whole clock, its two numerals and its face together. It falls\n * back to the dictionary, and a consumer `aria-label` wins over it.\n */\n label?: string\n}\n\nconst props = withDefaults(defineProps<TimePickerProps>(), {\n format: undefined,\n locale: undefined,\n minuteStep: 1,\n min: undefined,\n max: undefined,\n allowedHours: undefined,\n allowedMinutes: undefined,\n disabled: false,\n readonly: false,\n label: undefined,\n})\n\n/** The time, always as a 24-hour \"HH:mm\" string whatever clock is displayed. */\nconst model = defineModel<string | null>({ default: null })\n\nconst emit = defineEmits<{\n /**\n * The reader has FINISHED, carrying the time as it stands: the minutes were settled from the\n * keyboard.\n */\n confirm: [value: string | null]\n}>()\n\ndefineSlots<{\n /** A strip at the foot of the clock, the place for actions such as Cancel and OK. */\n footer?(): unknown\n}>()\n\nconst m = useMessages()\n// @a11y\n// A roleless box cannot carry an accessible name (axe: aria-prohibited-attr),\n// so the root is a named group: the VCarousel viewport arrangement.\nconst ariaLabel = useAriaLabel(() => props.label ?? m.value.timePicker.label)\nconst resolvedLocale = useResolvedLocale(() => props.locale)\nconst resolvedFormat = computed<TimePickerFormat>(\n () => props.format ?? hourCycleFor(resolvedLocale.value),\n)\n\n/** The restrictions, resolved once for the whole render. */\nconst limits = computed(() => resolveLimits(props))\n\n/** The hours the restrictions still leave something in, worked out once per set of restrictions. */\nconst availableHours = computed(() => {\n const hours = new Set<number>()\n for (let candidate = 0; candidate < 24; candidate += 1)\n if (isHourAllowed(candidate, limits.value)) hours.add(candidate)\n return hours\n})\n\n/** Whether an hour is one the restrictions still leave something in. */\nconst isAvailableHour = (candidate: number) => availableHours.value.has(candidate)\n\n// @devwarn\nif (isDev && !inject(hostWarnsKey, false)) {\n watchEffect(() => {\n if (!Number.isInteger(props.minuteStep) || props.minuteStep < 1 || 60 % props.minuteStep !== 0)\n console.warn(`[VTimePicker] minuteStep ${props.minuteStep} — a divisor of 60 is expected.`)\n const problem = limitsProblem(limits.value)\n if (problem) console.warn(`[VTimePicker] ${problem}`)\n })\n}\n\n/** Which of the two is being adjusted. It starts on the hour and moves on by itself. */\nconst step = ref<TimePickerStep>('hour')\n\n// With no value the clock shows the first hour of the half of the day chosen so far, which\n// is midnight until PM is picked. It is also the hour a time started from the MINUTES is\n// written with, so that choice is not lost when the reader begins there.\nconst parts = computed(\n () =>\n parseTime(model.value) ?? {\n hour: resolvedFormat.value === '12h' ? to24h(12, pendingMeridiem.value) : 0,\n minute: 0,\n },\n)\nconst hour = computed(() => parts.value.hour)\nconst minute = computed(() => parts.value.minute)\n\n/*\n * The two writers are the SINGLE cut-off point for the restrictions: the pointer, the six\n * keys and the AM/PM control all write through them and through nothing else, so not one\n * handler carries a guard of its own.\n */\n\n/**\n * Writing the hour. An hour with nothing left in it has no minute to fall back on, which\n * refuses it; one that is only PARTLY available pulls the minutes to what it does allow, since\n * `min: '09:30'` must not be left holding 09:00.\n */\nfunction setHour(value: number | null) {\n // Changing which face is shown stays allowed under `readonly`: reading the minutes is not\n // changing them.\n if (props.disabled || props.readonly) return\n if (value === null) return\n const minutes = isTimeAllowed(value, minute.value, limits.value)\n ? minute.value\n : nearestAllowedMinute(value, minute.value, limits.value)\n if (minutes === null) return\n model.value = formatTime(value, minutes)\n}\n\n/** Writing the minutes. Null is \"there was nowhere to go\", which every key can produce. */\nfunction setMinute(value: number | null) {\n if (props.disabled || props.readonly) return\n if (value === null || !isTimeAllowed(hour.value, value, limits.value)) return\n model.value = formatTime(hour.value, value)\n}\n\n// @a11y\n/*\n * Which step the clock is on is otherwise carried by the face's own name alone, and a change of\n * name is not reliably announced. A politely announced region says it a second time.\n */\nconst liveMessage = ref('')\n\nfunction setStep(next: TimePickerStep) {\n step.value = next\n liveMessage.value =\n next === 'minute' ? m.value.timePicker.choosingMinutes : m.value.timePicker.choosingHour\n}\n\n/** One numeral on the face. */\ninterface DialCell {\n key: string\n /** What is printed. */\n label: string\n /** Where it sits, as a fraction of a full turn from twelve o'clock. */\n turn: number\n /** Which of the two circles it belongs to, a 24-hour face having an inner one. */\n ring: 'outer' | 'inner'\n /** Whether it is the value currently being pointed at. */\n selected: boolean\n}\n\n/** The hour a position on the face stands for. */\nfunction hourAt(index: number, ring: 'outer' | 'inner'): number {\n return resolvedFormat.value === '24h'\n ? dialIndexToHour24(index, ring)\n : to24h(index === 0 ? 12 : index, currentMeridiem.value)\n}\n\n/** The minutes the face prints. */\nconst minuteMarks = computed(() => {\n const interval = minuteInterval(props.minuteStep)\n const spacing = 60 / interval <= 12 ? interval : 5\n const marks: number[] = []\n for (let minutes = 0; minutes < 60; minutes += spacing)\n if (minutes % interval === 0 && isTimeAllowed(hour.value, minutes, limits.value))\n marks.push(minutes)\n return marks\n})\n\n/*\n * A numeral that is only there to be refused says nothing the missing numeral does not say\n * better, and it says it in grey text a screen reader has no way to reach: the markers are\n * `aria-hidden`, so what the restrictions leave is spoken by the face's own value and by\n * nothing else.\n */\nconst cells = computed<DialCell[]>(() => {\n if (step.value === 'minute') {\n return minuteMarks.value.map((minutes) => ({\n key: `m-${minutes}`,\n label: pad2(minutes),\n turn: minutes / 60,\n ring: 'outer' as const,\n selected: minute.value === minutes,\n }))\n }\n // Each position's hour is worked out once, since on a 12-hour face that takes the half of the\n // day in force. The selection there still compares numerals and not hours: an empty clock\n // holding a PM choice sits on midnight, and its hand points at the 12 all the same.\n const twelve = resolvedFormat.value === '12h'\n const shown12 = to12h(hour.value).hour\n const rings: DialCell['ring'][] = twelve ? ['outer'] : ['outer', 'inner']\n const out: DialCell[] = []\n for (const ring of rings)\n for (let i = 0; i < 12; i += 1) {\n const target = hourAt(i, ring)\n if (!isAvailableHour(target)) continue\n const numeral = i === 0 ? 12 : i\n out.push({\n key: `${ring === 'outer' ? 'o' : 'i'}-${i}`,\n label: ring === 'inner' ? pad2(target) : String(numeral),\n turn: i / 12,\n ring,\n selected: twelve ? shown12 === numeral : hour.value === target,\n })\n }\n return out\n})\n\nconst handTurn = computed(() => {\n if (step.value === 'minute') return minute.value / 60\n const index =\n resolvedFormat.value === '24h' ? hour24ToDial(hour.value).index : to12h(hour.value).hour % 12\n return index / 12\n})\n\nconst handRing = computed(() =>\n step.value === 'hour' && resolvedFormat.value === '24h' ? hour24ToDial(hour.value).ring : 'outer',\n)\n\n/**\n * Whether the hand points at a minute that has no marker of its own. The tip is then\n * drawn small: at full size it would cover the two neighbouring markers and read as\n * pointing at neither.\n */\nconst handMinor = computed(\n () => step.value === 'minute' && !minuteMarks.value.includes(minute.value),\n)\n\n/** The hour as the two large numerals show it, on whichever clock is displayed. */\nconst displayHourText = computed(() =>\n pad2(resolvedFormat.value === '12h' ? to12h(hour.value).hour : hour.value),\n)\n\n/** Which half of the day is chosen while no time is set at all. */\nconst pendingMeridiem = ref<Meridiem>('AM')\n\n/**\n * The half of the day in force: the value's own once there is one, the remembered choice until\n * then.\n */\nconst currentMeridiem = computed<Meridiem>(() =>\n model.value ? to12h(hour.value).meridiem : pendingMeridiem.value,\n)\n\nconst meridiemModel = computed<ToggleModelValue>({\n get: () => currentMeridiem.value,\n set: (value) => {\n const meridiem: Meridiem = value === 'PM' ? 'PM' : 'AM'\n pendingMeridiem.value = meridiem\n // With no time set there is nothing to convert, so the choice is simply REMEMBERED\n // and applies to the first time chosen.\n if (!model.value) return\n // What is being chosen here is the HALF OF THE DAY, so the hour gives way to it: kept\n // when the new half allows it, and otherwise the first hour of that half that does.\n // Refusing the write instead would leave the control snapping straight back, since it\n // reads the half of the day off the value.\n const wanted = hourWithMeridiem(hour.value, meridiem)\n setHour(\n isAvailableHour(wanted)\n ? wanted\n : firstAllowed(12, (i) => to24h(i, meridiem), isAvailableHour),\n )\n },\n})\n\n/**\n * Whether each half of the day still holds an hour one could choose. An empty one takes its\n * button with it: a control that can only ever be refused is worse than no control.\n */\nconst meridiemAvailable = computed<Record<Meridiem, boolean>>(() => ({\n AM: firstAllowed(12, (i) => to24h(i, 'AM'), isAvailableHour) !== null,\n PM: firstAllowed(12, (i) => to24h(i, 'PM'), isAvailableHour) !== null,\n}))\n\n// @a11y\n// The entire spoken value of the face. The numerals are hidden from screen readers, so these\n// four attributes are the only thing assistive technology has: what the value is, what its\n// bounds are, and how to say it.\nconst ariaValueNow = computed(() => {\n if (step.value === 'minute') return minute.value\n return resolvedFormat.value === '12h' ? to12h(hour.value).hour : hour.value\n})\nconst ariaValueMin = computed(() =>\n step.value === 'minute' ? 0 : resolvedFormat.value === '12h' ? 1 : 0,\n)\nconst ariaValueMax = computed(() =>\n step.value === 'minute' ? 59 : resolvedFormat.value === '12h' ? 12 : 23,\n)\n// On a 12-hour face the half of the day is spoken with the hour: the AM/PM control is not\n// on the slider's way, and \"9 o'clock\" alone is two different times.\nconst ariaValueText = computed(() => {\n const m_ = m.value.timePicker\n if (step.value === 'minute') return m_.minutesValue(minute.value)\n const spoken = m_.hourValue(ariaValueNow.value)\n if (resolvedFormat.value === '24h') return spoken\n return `${spoken} ${currentMeridiem.value === 'PM' ? m_.pm : m_.am}`\n})\n\n/**\n * A step has been settled: after the hour come the minutes, and after the minutes the whole\n * thing is confirmed; but by KEYBOARD only, for the reason given on the emit.\n */\nfunction settleStep(via: 'pointer' | 'keyboard') {\n if (step.value === 'hour') setStep('minute')\n else if (via === 'keyboard') emit('confirm', model.value)\n}\n\nconst faceEl = ref<HTMLElement | null>(null)\nconst dragging = ref(false)\n/** Whether anything in the gesture under way landed on a numeral. */\nlet landed = false\n\n/**\n * Where a point on the face lands, and whether there was anything there at all. The write\n * itself still goes through `setHour`/ `setMinute` and their own guards, which is why\n * `readonly` is refused THERE and not here: a frozen clock is still one whose numerals are\n * real, and a tap on one moves the step on as it always did.\n */\nfunction applyPoint(event: PointerEvent): boolean {\n const face = faceEl.value\n // A disabled clock aims at nothing, so no step is moved on either: its header buttons\n // are disabled for the same reason.\n if (!face || props.disabled) return false\n // Measuring the face is safe here: this runs from a handler, hence in a browser, never\n // during a render.\n const rect = face.getBoundingClientRect()\n const dx = event.clientX - (rect.left + rect.width / 2)\n const dy = event.clientY - (rect.top + rect.height / 2)\n if (Math.hypot(dx, dy) <= DIAL_DEAD_ZONE * (rect.width / 2)) return false\n if (step.value === 'minute') {\n // The step's own snapping stands: a point between two markers has always been pulled\n // to the one it is nearest. What it is pulled to still has to BE on the face.\n const minutes = snapMinute(angleToIndex(dx, dy, 60), props.minuteStep)\n if (!isTimeAllowed(hour.value, minutes, limits.value)) return false\n setMinute(minutes)\n return true\n }\n const index = angleToIndex(dx, dy, 12)\n const ring =\n resolvedFormat.value === '24h' &&\n distanceFraction(dx, dy, rect.width / 2) < DIAL_INNER_THRESHOLD\n ? 'inner'\n : 'outer'\n const target = hourAt(index, ring)\n if (!isAvailableHour(target)) return false\n setHour(target)\n return true\n}\n\nfunction onPointerdown(event: PointerEvent) {\n // @fallback\n // Capture keeps dragging active outside the clock face; synthetic events may not reference a\n // capturable pointer.\n try {\n faceEl.value?.setPointerCapture(event.pointerId)\n } catch {\n /* Synthetic pointers cannot be captured. */\n }\n dragging.value = true\n landed = applyPoint(event)\n}\n\nfunction onPointermove(event: PointerEvent) {\n if (dragging.value && applyPoint(event)) landed = true\n}\n\nfunction onPointerup() {\n if (!dragging.value) return\n dragging.value = false\n // A gesture that never landed on a numeral has chosen nothing, so there is nothing to\n // move on FROM: the reader is left on the face they aimed at, looking at what they missed.\n if (landed) settleStep('pointer')\n}\n\nfunction onPointercancel() {\n dragging.value = false\n}\n\n// @keyboard @a11y\n// The keyboard a slider is expected to have.\n/*\n * Every key below walks until it finds something it MAY land on, rather than stopping at the\n * first thing it may not: with scattered hours a key that stopped would die at the first hole\n * and never reach what lies past it. Against a bound there is nothing past the hole, so the\n * same walk comes back empty and the key holds still, which a bound means.\n */\nfunction moveHour(delta: number) {\n if (resolvedFormat.value === '24h') {\n setHour(firstAllowed(23, (i) => (hour.value + delta * i + 24) % 24, isAvailableHour))\n return\n }\n // On a 12-hour face the hours cycle from 1 to 12 WITHIN the current half of the day:\n // passing midday is done on the AM/PM control, not by walking the hand past twelve.\n const hour12 = to12h(hour.value).hour\n setHour(\n firstAllowed(\n 11,\n (i) => to24h(((hour12 - 1 + delta * i + 12) % 12) + 1, currentMeridiem.value),\n isAvailableHour,\n ),\n )\n}\n\n/** The first or the last hour the face allows, on whichever clock it is showing. */\nfunction edgeHour(edge: 'first' | 'last'): number | null {\n if (resolvedFormat.value === '24h')\n return firstAllowed(24, (i) => (edge === 'first' ? i - 1 : 24 - i), isAvailableHour)\n return firstAllowed(\n 12,\n (i) => to24h(edge === 'first' ? i : 13 - i, currentMeridiem.value),\n isAvailableHour,\n )\n}\n\n/**\n * The minute a key lands on: the first step of the grid at least `distance` minutes away, that\n * way round, and then the next one the hour allows.\n */\nfunction minuteToward(direction: 1 | -1, distance: number): number | null {\n const grid = minuteGrid(props.minuteStep)\n const target = (((minute.value + direction * distance) % 60) + 60) % 60\n const n = grid.length\n let start = direction > 0 ? 0 : n - 1\n for (let k = 0; k < n; k += 1) {\n const i = direction > 0 ? k : n - 1 - k\n if (direction > 0 ? grid[i]! >= target : grid[i]! <= target) {\n start = i\n break\n }\n }\n return firstAllowed(\n n,\n (i) => grid[(((start + direction * (i - 1)) % n) + n) % n]!,\n (candidate) => isTimeAllowed(hour.value, candidate, limits.value),\n )\n}\n\nfunction onKeydown(event: KeyboardEvent) {\n // The face of a disabled clock is out of the tab order, but `focus()` can still put the\n // focus there: nothing it is sent may move the step on or confirm.\n if (props.disabled) return\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n settleStep('keyboard')\n return\n }\n // The arrows are not flipped in a right-to-left page: a clock face is never mirrored;\n // clockwise means the same thing everywhere; so forward stays forward.\n const delta =\n event.key === 'ArrowUp' || event.key === 'ArrowRight'\n ? 1\n : event.key === 'ArrowDown' || event.key === 'ArrowLeft'\n ? -1\n : 0\n if (step.value === 'hour') {\n if (delta) moveHour(delta)\n else if (event.key === 'Home') setHour(edgeHour('first'))\n else if (event.key === 'End') setHour(edgeHour('last'))\n else return\n event.preventDefault()\n return\n }\n if (delta) setMinute(minuteToward(delta, 1))\n else if (event.key === 'PageUp') setMinute(minuteToward(1, 5))\n else if (event.key === 'PageDown') setMinute(minuteToward(-1, 5))\n // The list is built in these two branches alone: the arrows walk to a neighbour and never\n // need the whole of it.\n else if (event.key === 'Home') setMinute(allowedMinutesFor(hour.value, limits.value)[0] ?? null)\n else if (event.key === 'End')\n setMinute(allowedMinutesFor(hour.value, limits.value).at(-1) ?? null)\n else return\n event.preventDefault()\n}\n\ndefineExpose({\n /** Moves the focus onto the clock face, which the arrow keys drive. */\n focus: (options?: FocusOptions) => faceEl.value?.focus(options),\n /**\n * Puts the clock back on the hour and clears the announcement, WITHOUT announcing anything\n * itself.\n */\n reset: () => {\n step.value = 'hour'\n liveMessage.value = ''\n },\n})\n</script>\n\n<template>\n <div\n class=\"v-time-picker\"\n role=\"group\"\n :aria-label=\"ariaLabel\"\n :data-step=\"step\"\n :data-disabled=\"disabled ? '' : undefined\"\n :data-readonly=\"readonly ? '' : undefined\"\n >\n <!--\n The two large numerals switch between adjusting the hour and the minutes. The one being\n adjusted takes the accent tone, which on a quiet button shows as the colour of the numeral\n rather than as a filled background.\n -->\n <div class=\"v-time-picker-header\">\n <div class=\"v-time-picker-time\">\n <VButton\n class=\"v-time-picker-cell\"\n variant=\"ghost\"\n size=\"lg\"\n :tone=\"step === 'hour' ? 'accent' : 'neutral'\"\n :aria-pressed=\"step === 'hour' ? 'true' : 'false'\"\n :aria-label=\"`${displayHourText}, ${m.timePicker.selectHour}`\"\n :disabled=\"disabled\"\n @click=\"setStep('hour')\"\n >\n {{ displayHourText }}\n </VButton>\n <span class=\"v-time-picker-sep\" aria-hidden=\"true\">:</span>\n <VButton\n class=\"v-time-picker-cell\"\n variant=\"ghost\"\n size=\"lg\"\n :tone=\"step === 'minute' ? 'accent' : 'neutral'\"\n :aria-pressed=\"step === 'minute' ? 'true' : 'false'\"\n :aria-label=\"`${pad2(minute)}, ${m.timePicker.selectMinutes}`\"\n :disabled=\"disabled\"\n @click=\"setStep('minute')\"\n >\n {{ pad2(minute) }}\n </VButton>\n </div>\n\n <VToggle\n v-if=\"resolvedFormat === '12h'\"\n v-model=\"meridiemModel\"\n class=\"v-time-picker-meridiem\"\n mandatory\n item-variant=\"outline\"\n selected-variant=\"soft\"\n orientation=\"vertical\"\n size=\"sm\"\n :label=\"m.timePicker.meridiem\"\n :disabled=\"disabled || readonly\"\n >\n <VToggleItem value=\"AM\" :label=\"m.timePicker.am\" :disabled=\"!meridiemAvailable.AM\" />\n <VToggleItem value=\"PM\" :label=\"m.timePicker.pm\" :disabled=\"!meridiemAvailable.PM\" />\n </VToggle>\n </div>\n\n <div\n ref=\"faceEl\"\n role=\"slider\"\n :tabindex=\"disabled ? -1 : 0\"\n class=\"v-time-picker-face\"\n :aria-label=\"step === 'hour' ? m.timePicker.hour : m.timePicker.minutes\"\n :aria-valuemin=\"ariaValueMin\"\n :aria-valuemax=\"ariaValueMax\"\n :aria-valuenow=\"ariaValueNow\"\n :aria-valuetext=\"ariaValueText\"\n :data-dragging=\"dragging ? '' : undefined\"\n :aria-disabled=\"disabled ? 'true' : undefined\"\n :aria-readonly=\"readonly ? 'true' : undefined\"\n @pointerdown=\"onPointerdown\"\n @pointermove=\"onPointermove\"\n @pointerup=\"onPointerup\"\n @pointercancel=\"onPointercancel\"\n @keydown=\"onKeydown\"\n >\n <div\n class=\"v-time-picker-hand\"\n aria-hidden=\"true\"\n :data-ring=\"handRing\"\n :data-minor=\"handMinor ? '' : undefined\"\n :style=\"{ '--dial-turn': String(handTurn) }\"\n />\n <span class=\"v-time-picker-center\" aria-hidden=\"true\" />\n <span\n v-for=\"cell in cells\"\n :key=\"cell.key\"\n class=\"v-time-picker-number\"\n aria-hidden=\"true\"\n :data-ring=\"cell.ring\"\n :data-selected=\"cell.selected ? '' : undefined\"\n :style=\"{ '--dial-turn': String(cell.turn) }\"\n >{{ cell.label }}</span\n >\n </div>\n\n <div class=\"v-visually-hidden\" aria-live=\"polite\">{{ liveMessage }}</div>\n\n <div v-if=\"$slots.footer\" class=\"v-time-picker-footer\"><slot name=\"footer\" /></div>\n </div>\n</template>\n\n<style>\n@layer vectis.components {\n /* The same root as VDatePicker's: an inline column that sizes to its content and pads\n itself, so the clock reads the same on a page as in VTimeInput's panel, which adds no\n padding of its own. The gap is the clock's, its parts being larger. */\n .v-time-picker {\n display: inline-flex;\n flex-direction: column;\n gap: var(--vectis-space-4);\n padding: var(--vectis-space-3);\n font-family: var(--vectis-text-family);\n color: var(--vectis-color-text);\n }\n\n /*\n * The numerals and the choice of half-day on one row, the second beside the first. The gap is\n * logical, so the control follows the reading direction; the numerals inside it stay in a\n * group of their own, which keeps the direction they force off it.\n */\n .v-time-picker-header {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: var(--vectis-space-3);\n }\n\n /* A time written in figures always reads hours then minutes, in every language. Forcing\n the direction here is what stops bidirectional reordering from swapping the two in a\n right-to-left page. */\n .v-time-picker-time {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: var(--vectis-space-2);\n direction: ltr;\n }\n\n /*\n * What is overridden here is the \"large numeral\" look alone; the width and the type; the\n * height, the states, the focus ring and the transitions all come from the button itself. The\n * type is the `heading-1` recipe taken whole, and the colon the `heading-2` one: read in\n * halves, a consumer repointing `--vectis-text-heading-1-weight` would move every heading but\n * these two numerals.\n */\n .v-time-picker-cell[data-size] {\n width: var(--control-height);\n font-size: var(--vectis-text-heading-1-size);\n font-weight: var(--vectis-text-heading-1-weight);\n line-height: var(--vectis-text-heading-1-leading);\n letter-spacing: var(--vectis-text-heading-1-tracking);\n /* Figures of equal width, so that going from \"11\" to \"00\" does not shift the cell. */\n font-variant-numeric: tabular-nums;\n }\n\n .v-time-picker-sep {\n font-size: var(--vectis-text-heading-2-size);\n font-weight: var(--vectis-text-heading-2-weight);\n line-height: var(--vectis-text-heading-2-leading);\n color: var(--vectis-color-text);\n user-select: none;\n }\n\n .v-time-picker-meridiem {\n flex: none;\n }\n\n .v-time-picker-face {\n position: relative;\n /*\n * The row above is wider than the face as soon as the half-day control is rendered, and the\n * column is only as wide as its widest child: without this the clock would hang at the\n * start edge of that row instead of under the middle of it. The face itself stays physical;\n * a clock is never mirrored.\n */\n align-self: center;\n inline-size: var(--vectis-control-size-time-picker-dial);\n block-size: var(--vectis-control-size-time-picker-dial);\n border-radius: var(--vectis-radius-pill);\n background: var(--vectis-color-surface-muted);\n /* Dragging across the face IS how one chooses, so it must do nothing else: no\n scrolling under a finger, and no selecting the numerals as text. */\n touch-action: none;\n user-select: none;\n cursor: pointer;\n /*\n * This is coupled to the threshold that decides which of the two circles a point belongs\n * to, in `utils/clock`. Changing one without the other makes the face answer with the wrong\n * ring, and no test can catch it: the unit tests measure nothing.\n */\n --dial-radius: calc(\n var(--vectis-control-size-time-picker-dial) / 2 -\n var(--vectis-control-size-time-picker-number) / 2\n );\n }\n\n .v-time-picker-face:focus-visible {\n outline: var(--vectis-focus-ring-width) solid var(--vectis-focus-ring-color);\n outline-offset: var(--vectis-focus-ring-offset);\n }\n\n /*\n * The properties used are PHYSICAL on purpose, where the rest of the design system prefers\n * logical ones: a clock face is never mirrored, clockwise meaning the same thing in every\n * language.\n */\n .v-time-picker-number {\n position: absolute;\n left: calc(50% + var(--dial-radius) * sin(var(--dial-turn) * 1turn));\n top: calc(50% - var(--dial-radius) * cos(var(--dial-turn) * 1turn));\n translate: -50% -50%;\n inline-size: var(--vectis-control-size-time-picker-number);\n block-size: var(--vectis-control-size-time-picker-number);\n display: grid;\n place-items: center;\n border-radius: var(--vectis-radius-pill);\n /* The outer circle is set at the large body size: these numerals are read at arm's\n length on a phone, not scanned like a label. */\n font-size: var(--vectis-text-body-lg-size);\n color: var(--vectis-color-text);\n /*\n * Above the hand, so that the numeral being pointed at reads on its dot rather than being\n * covered by it.\n */\n z-index: 1;\n pointer-events: none;\n }\n\n .v-time-picker-number[data-ring='inner'],\n .v-time-picker-hand[data-ring='inner'] {\n --dial-radius: calc(\n var(--vectis-control-size-time-picker-dial) / 2 -\n var(--vectis-control-size-time-picker-number) * 1.5\n );\n }\n\n .v-time-picker-number[data-ring='inner'] {\n font-size: var(--vectis-text-body-md-size);\n color: var(--vectis-color-text-muted);\n }\n\n .v-time-picker-number[data-selected] {\n color: var(--vectis-color-text-on-accent);\n }\n\n /*\n * The rotation is deliberately not animated. Going from 55 minutes to 0 takes the angle from\n * nearly a full turn back to none, and an interpolation would sweep the hand all the way\n * round anticlockwise.\n */\n .v-time-picker-hand {\n position: absolute;\n left: calc(50% - var(--vectis-control-size-time-picker-hand) / 2);\n bottom: 50%;\n inline-size: var(--vectis-control-size-time-picker-hand);\n block-size: var(--dial-radius);\n background: var(--vectis-color-accent);\n transform-origin: 50% 100%;\n rotate: calc(var(--dial-turn) * 1turn);\n }\n\n /*\n * The dot at the tip of the hand. It is exactly the size of a numeral's cell, so it covers\n * the one being pointed at; whose text turns to the colour that reads on it.\n */\n .v-time-picker-hand::before {\n content: '';\n position: absolute;\n top: 0;\n left: 50%;\n translate: -50% -50%;\n inline-size: var(--vectis-control-size-time-picker-number);\n block-size: var(--vectis-control-size-time-picker-number);\n border-radius: var(--vectis-radius-pill);\n background: var(--vectis-color-accent);\n /* The ONE thing animated on the hand: the dot changing size between its two forms. It\n interpolates between two bounded values and can therefore never take a wrong path,\n unlike the rotation above. */\n transition:\n inline-size var(--vectis-duration-fast) var(--vectis-ease-default),\n block-size var(--vectis-duration-fast) var(--vectis-ease-default);\n }\n\n /*\n * Only the DOT's own size changes. Redefining the numeral size variable here would also move\n * the radius derived from it, and with it the hand's length and the position of both circles.\n */\n .v-time-picker-hand[data-minor]::before {\n inline-size: var(--vectis-control-size-time-picker-hand-minor);\n block-size: var(--vectis-control-size-time-picker-hand-minor);\n }\n\n .v-time-picker-center {\n position: absolute;\n left: 50%;\n top: 50%;\n translate: -50% -50%;\n inline-size: var(--vectis-control-size-time-picker-center);\n block-size: var(--vectis-control-size-time-picker-center);\n border-radius: var(--vectis-radius-pill);\n background: var(--vectis-color-accent);\n }\n\n /* Neither state is a pointer target. A read-only clock is still read, so it keeps the\n ordinary cursor; a disabled one refuses. */\n .v-time-picker[data-readonly] .v-time-picker-face {\n cursor: default;\n }\n\n .v-time-picker[data-disabled] .v-time-picker-face {\n cursor: not-allowed;\n }\n\n /*\n * The whole block sits after the rules it overrides, every one of them at the same\n * specificity: this is one sheet, so its own order is what settles them.\n */\n .v-time-picker[data-disabled] .v-time-picker-hand,\n .v-time-picker[data-disabled] .v-time-picker-hand::before,\n .v-time-picker[data-disabled] .v-time-picker-center {\n background: var(--vectis-color-border-strong);\n }\n\n .v-time-picker[data-disabled] .v-time-picker-sep,\n .v-time-picker[data-disabled] .v-time-picker-number,\n .v-time-picker[data-disabled] .v-time-picker-number[data-selected] {\n color: var(--vectis-color-text-subtle);\n }\n\n .v-time-picker-footer {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: var(--vectis-space-2);\n padding-block-start: var(--vectis-space-2);\n border-block-start: 1px solid var(--vectis-color-border);\n }\n\n @media (prefers-reduced-motion: reduce) {\n .v-time-picker-hand::before {\n transition: none;\n }\n }\n}\n</style>\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"VToggle.js","names":[],"sources":["../../../src/components/VToggle/VToggle.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @keyboard @core\n/**\n * Reuse segmented native buttons with aria-pressed. JavaScript synchronizes selection and\n * provides group keyboard navigation.\n */\nimport { computed, provide, ref } from 'vue'\n\nimport VButtonGroup from '../VButton/VButtonGroup.vue'\nimport { toggleKey } from './context'\n\nimport { toggleValue } from '../../utils/array'\nimport { arrowNavigate, navigableItems } from '../../utils/arrowNav'\n\nimport type { ItemValue } from '../../types'\n\n/**\n * What identifies an item. It is the design system's own `ItemValue`, re-exported\n * under the family's name so a consumer typing a toggle need not reach for another module.\n */\nexport type ToggleValue = ItemValue\n/**\n * What the v-model holds, which follows `multiple`: one value or nothing when a single\n * choice is allowed, and a list of them when several are.\n */\nexport type ToggleModelValue = ToggleValue | ToggleValue[] | null\n/** How the items that are not selected are drawn. */\nexport type ToggleItemVariant = 'ghost' | 'outline'\n/** How a selected item is drawn. */\nexport type ToggleSelectedVariant = 'solid' | 'soft' | 'ghost'\n/** The colour of a selected item. */\nexport type ToggleTone = 'accent' | 'neutral' | 'danger'\n/** The height of the items, from the scale every control shares. */\nexport type ToggleSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'\n/** Whether the items run across the page or down it. */\nexport type ToggleOrientation = 'horizontal' | 'vertical'\n\ninterface ToggleProps {\n /** Allows several items to be chosen at once, which makes the v-model a list. */\n multiple?: boolean\n /**\n * Refuses to let the last chosen item be given up, so that something is always\n * selected once something has been. It is a guard and nothing more: it selects\n * nothing on its own at the start.\n */\n mandatory?: boolean\n /**\n * Leaves the items as separate buttons with a gap between them. Left out, they are\n * joined into one segmented control.\n */\n detached?: boolean\n /**\n * Draws a line between the joined items, so the row reads as segments rather than as\n * one frame. It has no effect under `detached`, where the items are separate buttons\n * already.\n */\n bordered?: boolean\n /** Whether the items run across the page or down it. */\n orientation?: ToggleOrientation\n /**\n * Stretches the row across the whole inline size of its parent, every item taking an equal\n * share of it, on the terms of VButtonGroup's own `fullWidth`.\n */\n fullWidth?: boolean\n /**\n * How the UNSELECTED items are drawn. 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?: ToggleItemVariant\n /**\n * How the SELECTED item is drawn, in the group's tone: filled, tinted, or the colour\n * of its text alone.\n */\n selectedVariant?: ToggleSelectedVariant\n /** The colour a selected item takes. The others stay neutral. */\n tone?: ToggleTone\n /** The height of the items, from the scale shared by every control. */\n size?: ToggleSize\n /** Takes 4px off the height of every item. */\n compact?: boolean\n /** Raises the row off the page, on the terms of VButtonGroup's own `elevated`. */\n elevated?: boolean\n /** Makes the whole group unusable. */\n disabled?: boolean\n /**\n * Draws the selected item's icon in its filled form, a common way of reinforcing\n * that it is the one in effect.\n */\n selectedIconFilled?: boolean\n /**\n * What screen readers announce for the group, such as \"Text alignment\" or \"Filters\". It is\n * strongly recommended: no default could say what a group of buttons is for.\n */\n label?: string\n}\n\nconst props = withDefaults(defineProps<ToggleProps>(), {\n multiple: false,\n mandatory: false,\n detached: false,\n bordered: false,\n orientation: 'horizontal',\n fullWidth: false,\n itemVariant: 'ghost',\n selectedVariant: 'solid',\n tone: 'accent',\n size: 'md',\n compact: false,\n elevated: false,\n disabled: false,\n selectedIconFilled: false,\n label: undefined,\n})\n\ndefineSlots<{\n /** The items of the group. */\n default(): unknown\n}>()\n\n/**\n * What is selected, and its shape follows `multiple`: a single value (or `null`, which is where\n * it starts) when one item may be chosen, and an array when several may. A null or scalar value\n * passed in `multiple` mode is read as an empty selection.\n */\nconst model = defineModel<ToggleModelValue>({ default: null })\n\nfunction isSelected(value: ToggleValue): boolean {\n return props.multiple\n ? Array.isArray(model.value) && model.value.includes(value)\n : model.value === value\n}\n\n/*\n * With several choices allowed, the list is never changed in place: each write produces a new\n * one. That is what makes a consumer's own watchers and computed values notice; mutating the\n * existing list would leave some of them silent.\n */\nfunction select(value: ToggleValue) {\n if (props.multiple) {\n const current = Array.isArray(model.value) ? model.value : []\n // `every` rather than a length of one: the removal takes out every copy of the value, so\n // a list holding it twice would otherwise be emptied past the guard.\n if (props.mandatory && current.length > 0 && current.every((v) => v === value)) return\n model.value = toggleValue(current, value)\n return\n }\n if (model.value === value) {\n if (!props.mandatory) model.value = null\n return\n }\n model.value = value\n}\n\nprovide(toggleKey, {\n isSelected,\n select,\n get itemVariant() {\n return props.itemVariant\n },\n get selectedVariant() {\n return props.selectedVariant\n },\n get tone() {\n return props.tone\n },\n get selectedIconFilled() {\n return props.selectedIconFilled\n },\n})\n\n// @keyboard @a11y\n/*\n * Every visible item stays a stop in the tab order here; the VPagination model. The single-stop\n * treatment is reserved for the patterns that require it, a row of tabs or a radio group, and a\n * group of buttons is neither.\n */\nfunction onKeydown(event: KeyboardEvent) {\n if (!(event.target as Element).matches('.v-toggle-item')) return\n const group = event.currentTarget as HTMLElement\n arrowNavigate(event, group, () => navigableItems(group, '.v-toggle-item:not(:disabled)'), {\n vertical: props.orientation === 'vertical',\n })\n}\n\nconst groupRef = ref<InstanceType<typeof VButtonGroup> | null>(null)\nconst groupEl = computed(() => groupRef.value?.el ?? null)\n\n// `focus` goes to the item that stands for the current choice, the one a keyboard user would\n// expect to resume from.\ndefineExpose({\n /**\n * Moves the focus to the selected item (the first of them when several are), or to the\n * first item that can take it when nothing is selected.\n */\n focus: (options?: FocusOptions) => {\n const group = groupEl.value\n const enabled = '.v-toggle-item:not(:disabled, [aria-disabled=\"true\"])'\n const item =\n group?.querySelector<HTMLElement>(`${enabled}[aria-pressed=\"true\"]`) ??\n group?.querySelector<HTMLElement>(enabled)\n item?.focus(options)\n },\n /** The `role=\"group\"` row, which is also where the consumer's attributes land. */\n el: groupEl,\n})\n</script>\n\n<template>\n <!--\n Pass default group booleans as undefined so absent opinions do not force false over\n individual toggle items.\n -->\n <VButtonGroup\n ref=\"groupRef\"\n class=\"v-toggle\"\n :orientation=\"orientation\"\n :detached=\"detached\"\n :bordered=\"bordered\"\n :full-width=\"fullWidth\"\n :size=\"size\"\n :compact=\"compact || undefined\"\n :elevated=\"elevated || undefined\"\n :disabled=\"disabled || undefined\"\n :label=\"label\"\n :data-item-variant=\"itemVariant\"\n @keydown=\"onKeydown\"\n >\n <slot />\n </VButtonGroup>\n</template>\n\n<style>\n@layer vectis.components {\n /*\n * The `:not(:where(.v-overlay))` guard weighs nothing and keeps a panel from passing for an\n * item; the pair is one specificity, so the arbitration with the group's cleared edges holds\n * on both shapes. The frame of an `outline` row, which only that row has: the colour its\n * unselected items already paint.\n */\n .v-toggle[data-item-variant='outline'] {\n --toggle-frame: var(--vectis-color-border-strong);\n }\n\n .v-toggle[data-item-variant='outline'] > .v-toggle-item:is(:disabled, [aria-disabled='true']),\n .v-toggle[data-item-variant='outline']\n > :not(:where(.v-overlay, .v-button-group))\n .v-toggle-item:is(:disabled, [aria-disabled='true']):not(:where(.v-overlay *)) {\n --toggle-frame: var(--vectis-color-border);\n }\n\n /*\n * `soft` and `ghost` leave VButton's border transparent, which in an outline row opens a gap\n * in the frame for the whole width of the selected segment. Restoring it on all four sides is\n * what keeps the frame closed wherever the selection sits, in either orientation and in RTL.\n */\n .v-toggle[data-item-variant='outline']\n > .v-toggle-item[aria-pressed='true']:is([data-variant='soft'], [data-variant='ghost']),\n .v-toggle[data-item-variant='outline']\n > :not(:where(.v-overlay, .v-button-group))\n .v-toggle-item[aria-pressed='true']:is([data-variant='soft'], [data-variant='ghost']):not(\n :where(.v-overlay *)\n ) {\n border-color: var(--toggle-frame);\n }\n\n /*\n * The class is repeated to reach (0,6,0). The variant, hover and active rules reach (0,5,0),\n * and with the forcing turned off any of them that still won would paint its tone over the\n * selection, with HighlightText on top of it.\n */\n @media (forced-colors: active) {\n .v-toggle-item.v-toggle-item.v-toggle-item.v-toggle-item[aria-pressed='true']:not(:disabled) {\n forced-color-adjust: none;\n background-color: Highlight;\n color: HighlightText;\n border-color: Highlight;\n outline-color: CanvasText;\n }\n }\n}\n</style>\n"],"mappings":""}
|
|
1
|
+
{"version":3,"file":"VToggle.js","names":[],"sources":["../../../src/components/VToggle/VToggle.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @keyboard @core\n/**\n * Reuse segmented native buttons with aria-pressed. JavaScript synchronizes selection and\n * provides group keyboard navigation.\n */\nimport { computed, provide, ref } from 'vue'\n\nimport VButtonGroup from '../VButton/VButtonGroup.vue'\nimport { toggleKey } from './context'\n\nimport { toggleValue } from '../../utils/array'\nimport { arrowNavigate, navigableItems } from '../../utils/arrowNav'\n\nimport type { ItemValue } from '../../types'\n\n/**\n * What identifies an item. It is the design system's own `ItemValue`, re-exported\n * under the family's name so a consumer typing a toggle need not reach for another module.\n */\nexport type ToggleValue = ItemValue\n/**\n * What the v-model holds, which follows `multiple`: one value or nothing when a single\n * choice is allowed, and a list of them when several are.\n */\nexport type ToggleModelValue = ToggleValue | ToggleValue[] | null\n/** How the items that are not selected are drawn. */\nexport type ToggleItemVariant = 'ghost' | 'outline'\n/** How a selected item is drawn. */\nexport type ToggleSelectedVariant = 'solid' | 'soft' | 'ghost'\n/** The colour of a selected item. */\nexport type ToggleTone = 'accent' | 'neutral' | 'danger'\n/** The height of the items, from the scale every control shares. */\nexport type ToggleSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'\n/** Whether the items run across the page or down it. */\nexport type ToggleOrientation = 'horizontal' | 'vertical'\n\ninterface ToggleProps {\n /** Allows several items to be chosen at once, which makes the v-model a list. */\n multiple?: boolean\n /**\n * Refuses to let the last chosen item be given up, so that something is always\n * selected once something has been. It is a guard and nothing more: it selects\n * nothing on its own at the start.\n */\n mandatory?: boolean\n /**\n * Leaves the items as separate buttons with a gap between them. Left out, they are\n * joined into one segmented control.\n */\n detached?: boolean\n /**\n * Draws a line between the joined items, so the row reads as segments rather than as\n * one frame. It has no effect under `detached`, where the items are separate buttons\n * already.\n */\n bordered?: boolean\n /** Whether the items run across the page or down it. */\n orientation?: ToggleOrientation\n /**\n * Stretches the row across the whole inline size of its parent, every item taking an equal\n * share of it, on the terms of VButtonGroup's own `fullWidth`.\n */\n fullWidth?: boolean\n /**\n * How the UNSELECTED items are drawn. 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?: ToggleItemVariant\n /**\n * How the SELECTED item is drawn, in the group's tone: filled, tinted, or the colour\n * of its text alone.\n */\n selectedVariant?: ToggleSelectedVariant\n /** The colour a selected item takes. The others stay neutral. */\n tone?: ToggleTone\n /** The height of the items, from the scale shared by every control. */\n size?: ToggleSize\n /** Takes 4px off the height of every item. */\n compact?: boolean\n /** Raises the row off the page, on the terms of VButtonGroup's own `elevated`. */\n elevated?: boolean\n /** Makes the whole group unusable. */\n disabled?: boolean\n /**\n * Draws the selected item's icon in its filled form, a common way of reinforcing\n * that it is the one in effect.\n */\n selectedIconFilled?: boolean\n /**\n * What screen readers announce for the group, such as \"Text alignment\" or \"Filters\". It is\n * strongly recommended: no default could say what a group of buttons is for.\n */\n label?: string\n}\n\nconst props = withDefaults(defineProps<ToggleProps>(), {\n multiple: false,\n mandatory: false,\n detached: false,\n bordered: false,\n orientation: 'horizontal',\n fullWidth: false,\n itemVariant: 'outline',\n selectedVariant: 'solid',\n tone: 'accent',\n size: 'md',\n compact: false,\n elevated: false,\n disabled: false,\n selectedIconFilled: false,\n label: undefined,\n})\n\ndefineSlots<{\n /** The items of the group. */\n default(): unknown\n}>()\n\n/**\n * What is selected, and its shape follows `multiple`: a single value (or `null`, which is where\n * it starts) when one item may be chosen, and an array when several may. A null or scalar value\n * passed in `multiple` mode is read as an empty selection.\n */\nconst model = defineModel<ToggleModelValue>({ default: null })\n\nfunction isSelected(value: ToggleValue): boolean {\n return props.multiple\n ? Array.isArray(model.value) && model.value.includes(value)\n : model.value === value\n}\n\n/*\n * With several choices allowed, the list is never changed in place: each write produces a new\n * one. That is what makes a consumer's own watchers and computed values notice; mutating the\n * existing list would leave some of them silent.\n */\nfunction select(value: ToggleValue) {\n if (props.multiple) {\n const current = Array.isArray(model.value) ? model.value : []\n // `every` rather than a length of one: the removal takes out every copy of the value, so\n // a list holding it twice would otherwise be emptied past the guard.\n if (props.mandatory && current.length > 0 && current.every((v) => v === value)) return\n model.value = toggleValue(current, value)\n return\n }\n if (model.value === value) {\n if (!props.mandatory) model.value = null\n return\n }\n model.value = value\n}\n\nprovide(toggleKey, {\n isSelected,\n select,\n get itemVariant() {\n return props.itemVariant\n },\n get selectedVariant() {\n return props.selectedVariant\n },\n get tone() {\n return props.tone\n },\n get selectedIconFilled() {\n return props.selectedIconFilled\n },\n})\n\n// @keyboard @a11y\n/*\n * Every visible item stays a stop in the tab order here; the VPagination model. The single-stop\n * treatment is reserved for the patterns that require it, a row of tabs or a radio group, and a\n * group of buttons is neither.\n */\nfunction onKeydown(event: KeyboardEvent) {\n if (!(event.target as Element).matches('.v-toggle-item')) return\n const group = event.currentTarget as HTMLElement\n arrowNavigate(event, group, () => navigableItems(group, '.v-toggle-item:not(:disabled)'), {\n vertical: props.orientation === 'vertical',\n })\n}\n\nconst groupRef = ref<InstanceType<typeof VButtonGroup> | null>(null)\nconst groupEl = computed(() => groupRef.value?.el ?? null)\n\n// `focus` goes to the item that stands for the current choice, the one a keyboard user would\n// expect to resume from.\ndefineExpose({\n /**\n * Moves the focus to the selected item (the first of them when several are), or to the\n * first item that can take it when nothing is selected.\n */\n focus: (options?: FocusOptions) => {\n const group = groupEl.value\n const enabled = '.v-toggle-item:not(:disabled, [aria-disabled=\"true\"])'\n const item =\n group?.querySelector<HTMLElement>(`${enabled}[aria-pressed=\"true\"]`) ??\n group?.querySelector<HTMLElement>(enabled)\n item?.focus(options)\n },\n /** The `role=\"group\"` row, which is also where the consumer's attributes land. */\n el: groupEl,\n})\n</script>\n\n<template>\n <!--\n Pass default group booleans as undefined so absent opinions do not force false over\n individual toggle items.\n -->\n <VButtonGroup\n ref=\"groupRef\"\n class=\"v-toggle\"\n :orientation=\"orientation\"\n :detached=\"detached\"\n :bordered=\"bordered\"\n :full-width=\"fullWidth\"\n :size=\"size\"\n :compact=\"compact || undefined\"\n :elevated=\"elevated || undefined\"\n :disabled=\"disabled || undefined\"\n :label=\"label\"\n :data-item-variant=\"itemVariant\"\n @keydown=\"onKeydown\"\n >\n <slot />\n </VButtonGroup>\n</template>\n\n<style>\n@layer vectis.components {\n /*\n * The `:not(:where(.v-overlay))` guard weighs nothing and keeps a panel from passing for an\n * item; the pair is one specificity, so the arbitration with the group's cleared edges holds\n * on both shapes. The frame of an `outline` row, which only that row has: the colour its\n * unselected items already paint.\n */\n .v-toggle[data-item-variant='outline'] {\n --toggle-frame: var(--vectis-color-border-strong);\n }\n\n .v-toggle[data-item-variant='outline'] > .v-toggle-item:is(:disabled, [aria-disabled='true']),\n .v-toggle[data-item-variant='outline']\n > :not(:where(.v-overlay, .v-button-group))\n .v-toggle-item:is(:disabled, [aria-disabled='true']):not(:where(.v-overlay *)) {\n --toggle-frame: var(--vectis-color-border);\n }\n\n /*\n * `soft` and `ghost` leave VButton's border transparent, which in an outline row opens a gap\n * in the frame for the whole width of the selected segment. Restoring it on all four sides is\n * what keeps the frame closed wherever the selection sits, in either orientation and in RTL.\n */\n .v-toggle[data-item-variant='outline']\n > .v-toggle-item[aria-pressed='true']:is([data-variant='soft'], [data-variant='ghost']),\n .v-toggle[data-item-variant='outline']\n > :not(:where(.v-overlay, .v-button-group))\n .v-toggle-item[aria-pressed='true']:is([data-variant='soft'], [data-variant='ghost']):not(\n :where(.v-overlay *)\n ) {\n border-color: var(--toggle-frame);\n }\n\n /*\n * The class is repeated to reach (0,6,0). The variant, hover and active rules reach (0,5,0),\n * and with the forcing turned off any of them that still won would paint its tone over the\n * selection, with HighlightText on top of it.\n */\n @media (forced-colors: active) {\n .v-toggle-item.v-toggle-item.v-toggle-item.v-toggle-item[aria-pressed='true']:not(:disabled) {\n forced-color-adjust: none;\n background-color: Highlight;\n color: HighlightText;\n border-color: Highlight;\n outline-color: CanvasText;\n }\n }\n}\n</style>\n"],"mappings":""}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"VToggle.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../../src/components/VToggle/VToggle.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @keyboard @core\n/**\n * Reuse segmented native buttons with aria-pressed. JavaScript synchronizes selection and\n * provides group keyboard navigation.\n */\nimport { computed, provide, ref } from 'vue'\n\nimport VButtonGroup from '../VButton/VButtonGroup.vue'\nimport { toggleKey } from './context'\n\nimport { toggleValue } from '../../utils/array'\nimport { arrowNavigate, navigableItems } from '../../utils/arrowNav'\n\nimport type { ItemValue } from '../../types'\n\n/**\n * What identifies an item. It is the design system's own `ItemValue`, re-exported\n * under the family's name so a consumer typing a toggle need not reach for another module.\n */\nexport type ToggleValue = ItemValue\n/**\n * What the v-model holds, which follows `multiple`: one value or nothing when a single\n * choice is allowed, and a list of them when several are.\n */\nexport type ToggleModelValue = ToggleValue | ToggleValue[] | null\n/** How the items that are not selected are drawn. */\nexport type ToggleItemVariant = 'ghost' | 'outline'\n/** How a selected item is drawn. */\nexport type ToggleSelectedVariant = 'solid' | 'soft' | 'ghost'\n/** The colour of a selected item. */\nexport type ToggleTone = 'accent' | 'neutral' | 'danger'\n/** The height of the items, from the scale every control shares. */\nexport type ToggleSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'\n/** Whether the items run across the page or down it. */\nexport type ToggleOrientation = 'horizontal' | 'vertical'\n\ninterface ToggleProps {\n /** Allows several items to be chosen at once, which makes the v-model a list. */\n multiple?: boolean\n /**\n * Refuses to let the last chosen item be given up, so that something is always\n * selected once something has been. It is a guard and nothing more: it selects\n * nothing on its own at the start.\n */\n mandatory?: boolean\n /**\n * Leaves the items as separate buttons with a gap between them. Left out, they are\n * joined into one segmented control.\n */\n detached?: boolean\n /**\n * Draws a line between the joined items, so the row reads as segments rather than as\n * one frame. It has no effect under `detached`, where the items are separate buttons\n * already.\n */\n bordered?: boolean\n /** Whether the items run across the page or down it. */\n orientation?: ToggleOrientation\n /**\n * Stretches the row across the whole inline size of its parent, every item taking an equal\n * share of it, on the terms of VButtonGroup's own `fullWidth`.\n */\n fullWidth?: boolean\n /**\n * How the UNSELECTED items are drawn. 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?: ToggleItemVariant\n /**\n * How the SELECTED item is drawn, in the group's tone: filled, tinted, or the colour\n * of its text alone.\n */\n selectedVariant?: ToggleSelectedVariant\n /** The colour a selected item takes. The others stay neutral. */\n tone?: ToggleTone\n /** The height of the items, from the scale shared by every control. */\n size?: ToggleSize\n /** Takes 4px off the height of every item. */\n compact?: boolean\n /** Raises the row off the page, on the terms of VButtonGroup's own `elevated`. */\n elevated?: boolean\n /** Makes the whole group unusable. */\n disabled?: boolean\n /**\n * Draws the selected item's icon in its filled form, a common way of reinforcing\n * that it is the one in effect.\n */\n selectedIconFilled?: boolean\n /**\n * What screen readers announce for the group, such as \"Text alignment\" or \"Filters\". It is\n * strongly recommended: no default could say what a group of buttons is for.\n */\n label?: string\n}\n\nconst props = withDefaults(defineProps<ToggleProps>(), {\n multiple: false,\n mandatory: false,\n detached: false,\n bordered: false,\n orientation: 'horizontal',\n fullWidth: false,\n itemVariant: 'ghost',\n selectedVariant: 'solid',\n tone: 'accent',\n size: 'md',\n compact: false,\n elevated: false,\n disabled: false,\n selectedIconFilled: false,\n label: undefined,\n})\n\ndefineSlots<{\n /** The items of the group. */\n default(): unknown\n}>()\n\n/**\n * What is selected, and its shape follows `multiple`: a single value (or `null`, which is where\n * it starts) when one item may be chosen, and an array when several may. A null or scalar value\n * passed in `multiple` mode is read as an empty selection.\n */\nconst model = defineModel<ToggleModelValue>({ default: null })\n\nfunction isSelected(value: ToggleValue): boolean {\n return props.multiple\n ? Array.isArray(model.value) && model.value.includes(value)\n : model.value === value\n}\n\n/*\n * With several choices allowed, the list is never changed in place: each write produces a new\n * one. That is what makes a consumer's own watchers and computed values notice; mutating the\n * existing list would leave some of them silent.\n */\nfunction select(value: ToggleValue) {\n if (props.multiple) {\n const current = Array.isArray(model.value) ? model.value : []\n // `every` rather than a length of one: the removal takes out every copy of the value, so\n // a list holding it twice would otherwise be emptied past the guard.\n if (props.mandatory && current.length > 0 && current.every((v) => v === value)) return\n model.value = toggleValue(current, value)\n return\n }\n if (model.value === value) {\n if (!props.mandatory) model.value = null\n return\n }\n model.value = value\n}\n\nprovide(toggleKey, {\n isSelected,\n select,\n get itemVariant() {\n return props.itemVariant\n },\n get selectedVariant() {\n return props.selectedVariant\n },\n get tone() {\n return props.tone\n },\n get selectedIconFilled() {\n return props.selectedIconFilled\n },\n})\n\n// @keyboard @a11y\n/*\n * Every visible item stays a stop in the tab order here; the VPagination model. The single-stop\n * treatment is reserved for the patterns that require it, a row of tabs or a radio group, and a\n * group of buttons is neither.\n */\nfunction onKeydown(event: KeyboardEvent) {\n if (!(event.target as Element).matches('.v-toggle-item')) return\n const group = event.currentTarget as HTMLElement\n arrowNavigate(event, group, () => navigableItems(group, '.v-toggle-item:not(:disabled)'), {\n vertical: props.orientation === 'vertical',\n })\n}\n\nconst groupRef = ref<InstanceType<typeof VButtonGroup> | null>(null)\nconst groupEl = computed(() => groupRef.value?.el ?? null)\n\n// `focus` goes to the item that stands for the current choice, the one a keyboard user would\n// expect to resume from.\ndefineExpose({\n /**\n * Moves the focus to the selected item (the first of them when several are), or to the\n * first item that can take it when nothing is selected.\n */\n focus: (options?: FocusOptions) => {\n const group = groupEl.value\n const enabled = '.v-toggle-item:not(:disabled, [aria-disabled=\"true\"])'\n const item =\n group?.querySelector<HTMLElement>(`${enabled}[aria-pressed=\"true\"]`) ??\n group?.querySelector<HTMLElement>(enabled)\n item?.focus(options)\n },\n /** The `role=\"group\"` row, which is also where the consumer's attributes land. */\n el: groupEl,\n})\n</script>\n\n<template>\n <!--\n Pass default group booleans as undefined so absent opinions do not force false over\n individual toggle items.\n -->\n <VButtonGroup\n ref=\"groupRef\"\n class=\"v-toggle\"\n :orientation=\"orientation\"\n :detached=\"detached\"\n :bordered=\"bordered\"\n :full-width=\"fullWidth\"\n :size=\"size\"\n :compact=\"compact || undefined\"\n :elevated=\"elevated || undefined\"\n :disabled=\"disabled || undefined\"\n :label=\"label\"\n :data-item-variant=\"itemVariant\"\n @keydown=\"onKeydown\"\n >\n <slot />\n </VButtonGroup>\n</template>\n\n<style>\n@layer vectis.components {\n /*\n * The `:not(:where(.v-overlay))` guard weighs nothing and keeps a panel from passing for an\n * item; the pair is one specificity, so the arbitration with the group's cleared edges holds\n * on both shapes. The frame of an `outline` row, which only that row has: the colour its\n * unselected items already paint.\n */\n .v-toggle[data-item-variant='outline'] {\n --toggle-frame: var(--vectis-color-border-strong);\n }\n\n .v-toggle[data-item-variant='outline'] > .v-toggle-item:is(:disabled, [aria-disabled='true']),\n .v-toggle[data-item-variant='outline']\n > :not(:where(.v-overlay, .v-button-group))\n .v-toggle-item:is(:disabled, [aria-disabled='true']):not(:where(.v-overlay *)) {\n --toggle-frame: var(--vectis-color-border);\n }\n\n /*\n * `soft` and `ghost` leave VButton's border transparent, which in an outline row opens a gap\n * in the frame for the whole width of the selected segment. Restoring it on all four sides is\n * what keeps the frame closed wherever the selection sits, in either orientation and in RTL.\n */\n .v-toggle[data-item-variant='outline']\n > .v-toggle-item[aria-pressed='true']:is([data-variant='soft'], [data-variant='ghost']),\n .v-toggle[data-item-variant='outline']\n > :not(:where(.v-overlay, .v-button-group))\n .v-toggle-item[aria-pressed='true']:is([data-variant='soft'], [data-variant='ghost']):not(\n :where(.v-overlay *)\n ) {\n border-color: var(--toggle-frame);\n }\n\n /*\n * The class is repeated to reach (0,6,0). The variant, hover and active rules reach (0,5,0),\n * and with the forcing turned off any of them that still won would paint its tone over the\n * selection, with HighlightText on top of it.\n */\n @media (forced-colors: active) {\n .v-toggle-item.v-toggle-item.v-toggle-item.v-toggle-item[aria-pressed='true']:not(:disabled) {\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAgGA,IAAM,IAAQ,GA4BR,IAAQ,EAA6B,GAAA,YAAkB;EAE7D,SAAS,EAAW,GAA6B;GAC/C,OAAO,EAAM,WACT,MAAM,QAAQ,EAAM,KAAK,KAAK,EAAM,MAAM,SAAS,CAAK,IACxD,EAAM,UAAU;EACtB;EAOA,SAAS,EAAO,GAAoB;GAClC,IAAI,EAAM,UAAU;IAClB,IAAM,IAAU,MAAM,QAAQ,EAAM,KAAK,IAAI,EAAM,QAAQ,CAAC;IAG5D,IAAI,EAAM,aAAa,EAAQ,SAAS,KAAK,EAAQ,OAAO,MAAM,MAAM,CAAK,GAAG;IAChF,EAAM,QAAQ,EAAY,GAAS,CAAK;IACxC;GACF;GACA,IAAI,EAAM,UAAU,GAAO;IACzB,AAAK,EAAM,cAAW,EAAM,QAAQ;IACpC;GACF;GACA,EAAM,QAAQ;EAChB;EAEA,EAAQ,GAAW;GACjB;GACA;GACA,IAAI,cAAc;IAChB,OAAO,EAAM;GACf;GACA,IAAI,kBAAkB;IACpB,OAAO,EAAM;GACf;GACA,IAAI,OAAO;IACT,OAAO,EAAM;GACf;GACA,IAAI,qBAAqB;IACvB,OAAO,EAAM;GACf;EACF,CAAC;EAQD,SAAS,EAAU,GAAsB;GACvC,IAAI,CAAE,EAAM,OAAmB,QAAQ,gBAAgB,GAAG;GAC1D,IAAM,IAAQ,EAAM;GACpB,EAAc,GAAO,SAAa,EAAe,GAAO,+BAA+B,GAAG,EACxF,UAAU,EAAM,gBAAgB,WAClC,CAAC;EACH;EAEA,IAAM,IAAW,EAA8C,IAAI,GAC7D,IAAU,QAAe,EAAS,OAAO,MAAM,IAAI;SAIzD,EAAa;GAKX,QAAQ,MAA2B;IACjC,IAAM,IAAQ,EAAQ,OAChB,IAAU;IAIhB,CAFE,GAAO,cAA2B,GAAG,EAAQ,sBAAsB,KACnE,GAAO,cAA2B,CAAO,EAAA,EACrC,MAAM,CAAO;GACrB;GAEA,IAAI;EACN,CAAC,mBAQC,EAgBe,GAAA;YAfT;GAAJ,KAAI;GACJ,OAAM;GACL,aAAa,EAAA;GACb,UAAU,EAAA;GACV,UAAU,EAAA;GACV,cAAY,EAAA;GACZ,MAAM,EAAA;GACN,SAAS,EAAA,WAAW,KAAA;GACpB,UAAU,EAAA,YAAY,KAAA;GACtB,UAAU,EAAA,YAAY,KAAA;GACtB,OAAO,EAAA;GACP,qBAAmB,EAAA;GACV;;oBAEF,CAAR,EAAQ,EAAA,QAAA,SAAA,CAAA,CAAA"}
|
|
1
|
+
{"version":3,"file":"VToggle.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../../src/components/VToggle/VToggle.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @keyboard @core\n/**\n * Reuse segmented native buttons with aria-pressed. JavaScript synchronizes selection and\n * provides group keyboard navigation.\n */\nimport { computed, provide, ref } from 'vue'\n\nimport VButtonGroup from '../VButton/VButtonGroup.vue'\nimport { toggleKey } from './context'\n\nimport { toggleValue } from '../../utils/array'\nimport { arrowNavigate, navigableItems } from '../../utils/arrowNav'\n\nimport type { ItemValue } from '../../types'\n\n/**\n * What identifies an item. It is the design system's own `ItemValue`, re-exported\n * under the family's name so a consumer typing a toggle need not reach for another module.\n */\nexport type ToggleValue = ItemValue\n/**\n * What the v-model holds, which follows `multiple`: one value or nothing when a single\n * choice is allowed, and a list of them when several are.\n */\nexport type ToggleModelValue = ToggleValue | ToggleValue[] | null\n/** How the items that are not selected are drawn. */\nexport type ToggleItemVariant = 'ghost' | 'outline'\n/** How a selected item is drawn. */\nexport type ToggleSelectedVariant = 'solid' | 'soft' | 'ghost'\n/** The colour of a selected item. */\nexport type ToggleTone = 'accent' | 'neutral' | 'danger'\n/** The height of the items, from the scale every control shares. */\nexport type ToggleSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'\n/** Whether the items run across the page or down it. */\nexport type ToggleOrientation = 'horizontal' | 'vertical'\n\ninterface ToggleProps {\n /** Allows several items to be chosen at once, which makes the v-model a list. */\n multiple?: boolean\n /**\n * Refuses to let the last chosen item be given up, so that something is always\n * selected once something has been. It is a guard and nothing more: it selects\n * nothing on its own at the start.\n */\n mandatory?: boolean\n /**\n * Leaves the items as separate buttons with a gap between them. Left out, they are\n * joined into one segmented control.\n */\n detached?: boolean\n /**\n * Draws a line between the joined items, so the row reads as segments rather than as\n * one frame. It has no effect under `detached`, where the items are separate buttons\n * already.\n */\n bordered?: boolean\n /** Whether the items run across the page or down it. */\n orientation?: ToggleOrientation\n /**\n * Stretches the row across the whole inline size of its parent, every item taking an equal\n * share of it, on the terms of VButtonGroup's own `fullWidth`.\n */\n fullWidth?: boolean\n /**\n * How the UNSELECTED items are drawn. 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?: ToggleItemVariant\n /**\n * How the SELECTED item is drawn, in the group's tone: filled, tinted, or the colour\n * of its text alone.\n */\n selectedVariant?: ToggleSelectedVariant\n /** The colour a selected item takes. The others stay neutral. */\n tone?: ToggleTone\n /** The height of the items, from the scale shared by every control. */\n size?: ToggleSize\n /** Takes 4px off the height of every item. */\n compact?: boolean\n /** Raises the row off the page, on the terms of VButtonGroup's own `elevated`. */\n elevated?: boolean\n /** Makes the whole group unusable. */\n disabled?: boolean\n /**\n * Draws the selected item's icon in its filled form, a common way of reinforcing\n * that it is the one in effect.\n */\n selectedIconFilled?: boolean\n /**\n * What screen readers announce for the group, such as \"Text alignment\" or \"Filters\". It is\n * strongly recommended: no default could say what a group of buttons is for.\n */\n label?: string\n}\n\nconst props = withDefaults(defineProps<ToggleProps>(), {\n multiple: false,\n mandatory: false,\n detached: false,\n bordered: false,\n orientation: 'horizontal',\n fullWidth: false,\n itemVariant: 'outline',\n selectedVariant: 'solid',\n tone: 'accent',\n size: 'md',\n compact: false,\n elevated: false,\n disabled: false,\n selectedIconFilled: false,\n label: undefined,\n})\n\ndefineSlots<{\n /** The items of the group. */\n default(): unknown\n}>()\n\n/**\n * What is selected, and its shape follows `multiple`: a single value (or `null`, which is where\n * it starts) when one item may be chosen, and an array when several may. A null or scalar value\n * passed in `multiple` mode is read as an empty selection.\n */\nconst model = defineModel<ToggleModelValue>({ default: null })\n\nfunction isSelected(value: ToggleValue): boolean {\n return props.multiple\n ? Array.isArray(model.value) && model.value.includes(value)\n : model.value === value\n}\n\n/*\n * With several choices allowed, the list is never changed in place: each write produces a new\n * one. That is what makes a consumer's own watchers and computed values notice; mutating the\n * existing list would leave some of them silent.\n */\nfunction select(value: ToggleValue) {\n if (props.multiple) {\n const current = Array.isArray(model.value) ? model.value : []\n // `every` rather than a length of one: the removal takes out every copy of the value, so\n // a list holding it twice would otherwise be emptied past the guard.\n if (props.mandatory && current.length > 0 && current.every((v) => v === value)) return\n model.value = toggleValue(current, value)\n return\n }\n if (model.value === value) {\n if (!props.mandatory) model.value = null\n return\n }\n model.value = value\n}\n\nprovide(toggleKey, {\n isSelected,\n select,\n get itemVariant() {\n return props.itemVariant\n },\n get selectedVariant() {\n return props.selectedVariant\n },\n get tone() {\n return props.tone\n },\n get selectedIconFilled() {\n return props.selectedIconFilled\n },\n})\n\n// @keyboard @a11y\n/*\n * Every visible item stays a stop in the tab order here; the VPagination model. The single-stop\n * treatment is reserved for the patterns that require it, a row of tabs or a radio group, and a\n * group of buttons is neither.\n */\nfunction onKeydown(event: KeyboardEvent) {\n if (!(event.target as Element).matches('.v-toggle-item')) return\n const group = event.currentTarget as HTMLElement\n arrowNavigate(event, group, () => navigableItems(group, '.v-toggle-item:not(:disabled)'), {\n vertical: props.orientation === 'vertical',\n })\n}\n\nconst groupRef = ref<InstanceType<typeof VButtonGroup> | null>(null)\nconst groupEl = computed(() => groupRef.value?.el ?? null)\n\n// `focus` goes to the item that stands for the current choice, the one a keyboard user would\n// expect to resume from.\ndefineExpose({\n /**\n * Moves the focus to the selected item (the first of them when several are), or to the\n * first item that can take it when nothing is selected.\n */\n focus: (options?: FocusOptions) => {\n const group = groupEl.value\n const enabled = '.v-toggle-item:not(:disabled, [aria-disabled=\"true\"])'\n const item =\n group?.querySelector<HTMLElement>(`${enabled}[aria-pressed=\"true\"]`) ??\n group?.querySelector<HTMLElement>(enabled)\n item?.focus(options)\n },\n /** The `role=\"group\"` row, which is also where the consumer's attributes land. */\n el: groupEl,\n})\n</script>\n\n<template>\n <!--\n Pass default group booleans as undefined so absent opinions do not force false over\n individual toggle items.\n -->\n <VButtonGroup\n ref=\"groupRef\"\n class=\"v-toggle\"\n :orientation=\"orientation\"\n :detached=\"detached\"\n :bordered=\"bordered\"\n :full-width=\"fullWidth\"\n :size=\"size\"\n :compact=\"compact || undefined\"\n :elevated=\"elevated || undefined\"\n :disabled=\"disabled || undefined\"\n :label=\"label\"\n :data-item-variant=\"itemVariant\"\n @keydown=\"onKeydown\"\n >\n <slot />\n </VButtonGroup>\n</template>\n\n<style>\n@layer vectis.components {\n /*\n * The `:not(:where(.v-overlay))` guard weighs nothing and keeps a panel from passing for an\n * item; the pair is one specificity, so the arbitration with the group's cleared edges holds\n * on both shapes. The frame of an `outline` row, which only that row has: the colour its\n * unselected items already paint.\n */\n .v-toggle[data-item-variant='outline'] {\n --toggle-frame: var(--vectis-color-border-strong);\n }\n\n .v-toggle[data-item-variant='outline'] > .v-toggle-item:is(:disabled, [aria-disabled='true']),\n .v-toggle[data-item-variant='outline']\n > :not(:where(.v-overlay, .v-button-group))\n .v-toggle-item:is(:disabled, [aria-disabled='true']):not(:where(.v-overlay *)) {\n --toggle-frame: var(--vectis-color-border);\n }\n\n /*\n * `soft` and `ghost` leave VButton's border transparent, which in an outline row opens a gap\n * in the frame for the whole width of the selected segment. Restoring it on all four sides is\n * what keeps the frame closed wherever the selection sits, in either orientation and in RTL.\n */\n .v-toggle[data-item-variant='outline']\n > .v-toggle-item[aria-pressed='true']:is([data-variant='soft'], [data-variant='ghost']),\n .v-toggle[data-item-variant='outline']\n > :not(:where(.v-overlay, .v-button-group))\n .v-toggle-item[aria-pressed='true']:is([data-variant='soft'], [data-variant='ghost']):not(\n :where(.v-overlay *)\n ) {\n border-color: var(--toggle-frame);\n }\n\n /*\n * The class is repeated to reach (0,6,0). The variant, hover and active rules reach (0,5,0),\n * and with the forcing turned off any of them that still won would paint its tone over the\n * selection, with HighlightText on top of it.\n */\n @media (forced-colors: active) {\n .v-toggle-item.v-toggle-item.v-toggle-item.v-toggle-item[aria-pressed='true']:not(:disabled) {\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAgGA,IAAM,IAAQ,GA4BR,IAAQ,EAA6B,GAAA,YAAkB;EAE7D,SAAS,EAAW,GAA6B;GAC/C,OAAO,EAAM,WACT,MAAM,QAAQ,EAAM,KAAK,KAAK,EAAM,MAAM,SAAS,CAAK,IACxD,EAAM,UAAU;EACtB;EAOA,SAAS,EAAO,GAAoB;GAClC,IAAI,EAAM,UAAU;IAClB,IAAM,IAAU,MAAM,QAAQ,EAAM,KAAK,IAAI,EAAM,QAAQ,CAAC;IAG5D,IAAI,EAAM,aAAa,EAAQ,SAAS,KAAK,EAAQ,OAAO,MAAM,MAAM,CAAK,GAAG;IAChF,EAAM,QAAQ,EAAY,GAAS,CAAK;IACxC;GACF;GACA,IAAI,EAAM,UAAU,GAAO;IACzB,AAAK,EAAM,cAAW,EAAM,QAAQ;IACpC;GACF;GACA,EAAM,QAAQ;EAChB;EAEA,EAAQ,GAAW;GACjB;GACA;GACA,IAAI,cAAc;IAChB,OAAO,EAAM;GACf;GACA,IAAI,kBAAkB;IACpB,OAAO,EAAM;GACf;GACA,IAAI,OAAO;IACT,OAAO,EAAM;GACf;GACA,IAAI,qBAAqB;IACvB,OAAO,EAAM;GACf;EACF,CAAC;EAQD,SAAS,EAAU,GAAsB;GACvC,IAAI,CAAE,EAAM,OAAmB,QAAQ,gBAAgB,GAAG;GAC1D,IAAM,IAAQ,EAAM;GACpB,EAAc,GAAO,SAAa,EAAe,GAAO,+BAA+B,GAAG,EACxF,UAAU,EAAM,gBAAgB,WAClC,CAAC;EACH;EAEA,IAAM,IAAW,EAA8C,IAAI,GAC7D,IAAU,QAAe,EAAS,OAAO,MAAM,IAAI;SAIzD,EAAa;GAKX,QAAQ,MAA2B;IACjC,IAAM,IAAQ,EAAQ,OAChB,IAAU;IAIhB,CAFE,GAAO,cAA2B,GAAG,EAAQ,sBAAsB,KACnE,GAAO,cAA2B,CAAO,EAAA,EACrC,MAAM,CAAO;GACrB;GAEA,IAAI;EACN,CAAC,mBAQC,EAgBe,GAAA;YAfT;GAAJ,KAAI;GACJ,OAAM;GACL,aAAa,EAAA;GACb,UAAU,EAAA;GACV,UAAU,EAAA;GACV,cAAY,EAAA;GACZ,MAAM,EAAA;GACN,SAAS,EAAA,WAAW,KAAA;GACpB,UAAU,EAAA,YAAY,KAAA;GACtB,UAAU,EAAA,YAAY,KAAA;GACtB,OAAO,EAAA;GACP,qBAAmB,EAAA;GACV;;oBAEF,CAAR,EAAQ,EAAA,QAAA,SAAA,CAAA,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"VTreeView.js","names":[],"sources":["../../../src/components/VTreeView/VTreeView.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @keyboard @a11y @core\n/**\n * A hierarchy following the ARIA tree pattern. The rows are rendered flat, in display order, their\n * depth carried by `aria-level`, `aria-setsize` and `aria-posinset`: no native element folds a tree\n * with a single tab stop, and a row that does not wrap its subtree keeps its accessible name free of\n * its descendants' text. JavaScript owns the focus, the keyboard, selection and lazy loading.\n */\nimport { computed, onMounted, ref, shallowReactive, watch } from 'vue'\nimport type { ComponentPublicInstance } from 'vue'\n\nimport VCheckMark from '../VCheckbox/VCheckMark.vue'\nimport VIcon from '../VIcon/VIcon.vue'\nimport VSpinner from '../VSpinner/VSpinner.vue'\nimport { iconProps } from '../VIcon/iconProps'\nimport { chevron_right as chevronRightIcon } from '../VIcon/icons/chevron_right'\nimport { error as errorIcon } from '../VIcon/icons/error'\nimport type { IconSource } from '../VIcon/types'\nimport type { ItemValue } from '../../types'\nimport { useAriaLabel } from '../../composables/useAriaLabel'\nimport { useLiveAnnouncer } from '../../composables/useLiveAnnouncer'\nimport { useRootAttrs } from '../../composables/useRootAttrs'\nimport { useTimer } from '../../composables/useTimer'\nimport { useMessages } from '../../i18n/state'\nimport { isRtl } from '../../utils/direction'\nimport { normalizeText } from '../../utils/text'\n\n/** Whether nodes can be selected: not at all, one at a time, or several with checkboxes. */\nexport type TreeSelectionMode = 'none' | 'single' | 'multiple'\n\n/** The height of the rows: 32 or 40 pixels. */\nexport type TreeViewSize = 'sm' | 'md'\n\n/**\n * What is selected, and its shape follows `selectionMode`: a single value (or `null`) in `single`\n * mode, an array in `multiple` mode.\n */\nexport type TreeViewModelValue = ItemValue | ItemValue[] | null\n\n/** One node of the tree. */\nexport interface TreeItem {\n /** What identifies the node, in the selection and expansion models. Unique across the tree. */\n value: ItemValue\n /** What the row says. It is also what typeahead matches. */\n label: string\n /** An icon before the label: an icon name, or an explicit render. */\n icon?: IconSource\n /** The child nodes, which make this node a branch. */\n children?: TreeItem[]\n /**\n * Marks a branch whose children `loadChildren` fetches on its first expansion. Ignored when\n * `children` is set or when the tree has no `loadChildren`.\n */\n lazy?: boolean\n /** Where the node leads, which makes the row a link. */\n href?: string\n /** Marks the node as the page being viewed (`aria-current`). */\n current?: boolean\n /**\n * Keeps the node focusable, so it is still found, but it cannot be selected, activated, expanded\n * or collapsed.\n */\n disabled?: boolean\n}\n\n/** What the `#icon`, `#label` and `#end` slots receive. */\nexport interface TreeItemSlotProps {\n item: TreeItem\n /** The depth of the node, from 1. */\n level: number\n /** Whether the node's children are shown. */\n expanded: boolean\n}\n\ntype CheckState = 'true' | 'false' | 'mixed'\n\ninterface TreeNode {\n item: TreeItem\n level: number\n parent: TreeNode | null\n posinset: number\n setsize: number\n}\n\ninterface TreeViewProps {\n /** The first level of the tree. */\n items: TreeItem[]\n /**\n * Whether nodes can be selected. In `multiple` mode each row has a checkbox, and checking a\n * branch checks everything under it.\n */\n selectionMode?: TreeSelectionMode\n /**\n * Fetches the children of a `lazy` node, on its first expansion. The tree keeps them; a failure\n * collapses the node again, and the next expansion retries.\n */\n loadChildren?: (item: TreeItem) => Promise<TreeItem[]>\n /** The height of the rows, 32 or 40 pixels. */\n size?: TreeViewSize\n /** What screen readers announce for the tree. It falls back to the design system dictionary. */\n label?: string\n}\n\ndefineOptions({ inheritAttrs: false })\n\nconst props = withDefaults(defineProps<TreeViewProps>(), {\n selectionMode: 'none',\n loadChildren: undefined,\n size: 'md',\n label: undefined,\n})\n\n/**\n * What is selected. In `single` mode, a value or `null`; in `multiple` mode, the checked nodes in\n * tree order, a branch included when everything under it is checked. A value given for a branch\n * checks its whole subtree. A shape that does not match the mode reads as an empty selection.\n */\nconst model = defineModel<TreeViewModelValue>({ default: null })\n\n/** The values of the expanded nodes. Left unbound, the tree keeps that state itself. */\nconst expanded = defineModel<ItemValue[]>('expanded', { default: () => [] })\n\nconst emit = defineEmits<{\n /**\n * A node was clicked or Enter was pressed on it, after the tree applied its own effect: selecting\n * it, checking it, or folding a branch in `none` mode. For a link, calling `preventDefault()` on\n * the event stops the navigation, which hands it to a router.\n */\n activate: [item: TreeItem, event: MouseEvent | KeyboardEvent]\n}>()\n\ndefineSlots<{\n /** Replaces the icon of a node, for instance to show an open folder when it is expanded. */\n icon?(props: TreeItemSlotProps): unknown\n /** Replaces the label of a node. */\n label?(props: TreeItemSlotProps): unknown\n /**\n * Content at the end of a row: a count, a badge. It must not be focusable: a row is a single\n * control.\n */\n end?(props: TreeItemSlotProps): unknown\n}>()\n\nconst { rootClass, rootStyle, forwardedAttrs } = useRootAttrs()\nconst m = useMessages()\nconst ariaLabel = useAriaLabel(() => props.label ?? m.value.treeView.label)\nconst { polite: announcement, announce } = useLiveAnnouncer()\n\nconst loaded = shallowReactive(new Map<ItemValue, TreeItem[]>())\nconst loading = shallowReactive(new Set<ItemValue>())\nconst failed = shallowReactive(new Set<ItemValue>())\n\nfunction childrenOf(item: TreeItem): TreeItem[] | undefined {\n return item.children ?? loaded.get(item.value)\n}\n\nfunction isPending(item: TreeItem): boolean {\n return !!props.loadChildren && !!item.lazy && !item.children && !loaded.has(item.value)\n}\n\nfunction isBranch(item: TreeItem): boolean {\n return !!childrenOf(item)?.length || isPending(item)\n}\n\nconst expandedSet = computed(() => new Set(expanded.value))\n\n/** The rows on screen, in display order: every node whose ancestors are all expanded. */\nconst visible = computed(() => {\n const rows: TreeNode[] = []\n const walk = (items: TreeItem[], level: number, parent: TreeNode | null) => {\n items.forEach((item, index) => {\n const node = { item, level, parent, posinset: index + 1, setsize: items.length }\n rows.push(node)\n const children = childrenOf(item)\n if (children && expandedSet.value.has(item.value)) walk(children, level + 1, node)\n })\n }\n walk(props.items, 1, null)\n return rows\n})\n\nfunction setExpanded(item: TreeItem, open: boolean) {\n if (open === expandedSet.value.has(item.value)) return\n expanded.value = open\n ? [...expanded.value, item.value]\n : expanded.value.filter((value) => value !== item.value)\n}\n\n// @core\n/**\n * Derives what each checkbox shows. A value in the selection checks its node and the whole subtree\n * under it; a branch then shows checked when all its children are, unchecked when none are, and\n * mixed otherwise, so its own presence in the selection only matters through its children. The map\n * is filled in tree order, which is the order the model is written in.\n */\nfunction deriveCheckStates(chosen: Set<ItemValue>): Map<ItemValue, CheckState> {\n const states = new Map<ItemValue, CheckState>()\n const visit = (item: TreeItem, inherited: boolean): CheckState => {\n states.set(item.value, 'false')\n const own = inherited || chosen.has(item.value)\n const children = childrenOf(item)\n let state: CheckState = own ? 'true' : 'false'\n if (children?.length) {\n const childStates = children.map((child) => visit(child, own))\n state = childStates.every((s) => s === 'true')\n ? 'true'\n : childStates.every((s) => s === 'false')\n ? 'false'\n : 'mixed'\n }\n states.set(item.value, state)\n return state\n }\n props.items.forEach((item) => visit(item, false))\n return states\n}\n\nconst chosenValues = computed<ItemValue[]>(() => {\n const value = model.value\n if (props.selectionMode === 'multiple') return Array.isArray(value) ? value : []\n return value === null || Array.isArray(value) ? [] : [value]\n})\n\nconst checkStates = computed(() =>\n props.selectionMode === 'multiple'\n ? deriveCheckStates(new Set(chosenValues.value))\n : new Map<ItemValue, CheckState>(),\n)\n\n/** Calls `fn` on a node and on every node under it that is not disabled. */\nfunction eachEnabled(item: TreeItem, fn: (item: TreeItem) => void) {\n fn(item)\n childrenOf(item)?.forEach((child) => {\n if (!child.disabled) eachEnabled(child, fn)\n })\n}\n\n// @core\n/**\n * Checks a node and its subtree when any leaf of it is unchecked, and unchecks them otherwise.\n * Disabled nodes keep their state. Unchecking also drops the ancestors, which would otherwise check\n * the node again through their subtree. Values the tree does not know, such as the children of a\n * node not loaded yet, are kept at the end.\n */\nfunction toggleCheck(node: TreeNode) {\n const states = checkStates.value\n const chosen = new Set<ItemValue>()\n states.forEach((state, value) => {\n if (state === 'true') chosen.add(value)\n })\n const unknown = chosenValues.value.filter((value) => !states.has(value))\n\n let check = false\n eachEnabled(node.item, (item) => {\n if (!childrenOf(item)?.length && states.get(item.value) !== 'true') check = true\n })\n // Only the leaves are added: a branch in the selection would check its disabled children too.\n eachEnabled(node.item, (item) => {\n if (!check) chosen.delete(item.value)\n else if (!childrenOf(item)?.length) chosen.add(item.value)\n })\n if (!check) for (let p = node.parent; p; p = p.parent) chosen.delete(p.item.value)\n\n const next: ItemValue[] = []\n deriveCheckStates(chosen).forEach((state, value) => {\n if (state === 'true') next.push(value)\n })\n model.value = [...next, ...unknown]\n}\n\nfunction isSelected(item: TreeItem): boolean {\n return props.selectionMode === 'single' && chosenValues.value[0] === item.value\n}\n\n// @core\n/** What a click or Enter does besides the `activate` event, which follows it. */\nfunction applyDefault(node: TreeNode) {\n const { item } = node\n if (props.selectionMode === 'single') model.value = item.value\n else if (props.selectionMode === 'multiple') toggleCheck(node)\n else if (item.href === undefined && isBranch(item))\n setExpanded(item, !expandedSet.value.has(item.value))\n}\n\nfunction activate(node: TreeNode, event: MouseEvent | KeyboardEvent) {\n applyDefault(node)\n emit('activate', node.item, event)\n}\n\nasync function load(item: TreeItem) {\n const loader = props.loadChildren\n if (!loader) return\n loading.add(item.value)\n failed.delete(item.value)\n try {\n loaded.set(item.value, await loader(item))\n } catch {\n failed.add(item.value)\n setExpanded(item, false)\n announce(m.value.treeView.loadError(item.label), false)\n } finally {\n loading.delete(item.value)\n }\n}\n\n// @ssr\n// Loading waits for the client: an expanded lazy node renders busy on the server, then fetches.\nonMounted(() => {\n // The expanded set is watched too: a pending node shows no children, so its expansion alone\n // does not change the rows.\n watch(\n [visible, expandedSet],\n ([rows, open]) => {\n for (const { item } of rows)\n if (open.has(item.value) && isPending(item) && !loading.has(item.value)) void load(item)\n },\n { immediate: true },\n )\n})\n\nconst treeEl = ref<HTMLElement | null>(null)\nconst rowEls = new Map<ItemValue, HTMLElement>()\nconst rowValues = new WeakMap<Element, ItemValue>()\n\nfunction bindRow(value: ItemValue, el: Element | ComponentPublicInstance | null) {\n if (el instanceof HTMLElement) {\n rowEls.set(value, el)\n rowValues.set(el, value)\n } else rowEls.delete(value)\n}\n\n// @keyboard @a11y\n/**\n * The single tab stop: the node last focused, or its closest ancestor still on screen once a\n * branch above it folds; before any focus, the first selected node on screen, then the current\n * one, then the first row.\n */\nconst active = ref<TreeNode | null>(null)\nconst tabStop = computed<ItemValue | undefined>(() => {\n const rows = visible.value\n const shown = new Set(rows.map((node) => node.item.value))\n for (let node = active.value; node; node = node.parent)\n if (shown.has(node.item.value)) return node.item.value\n const selected =\n props.selectionMode === 'multiple'\n ? rows.find((node) => checkStates.value.get(node.item.value) === 'true')\n : rows.find((node) => isSelected(node.item))\n return (selected ?? rows.find((node) => node.item.current) ?? rows[0])?.item.value\n})\n\nfunction onFocusin(event: FocusEvent) {\n const value = rowValues.get(event.target as Element)\n if (value === undefined) return\n active.value = visible.value.find((node) => node.item.value === value) ?? null\n}\n\nfunction focusRow(node: TreeNode | undefined) {\n if (node) rowEls.get(node.item.value)?.focus()\n}\n\nconst typeahead = useTimer()\nlet typed = ''\n\n// @keyboard\n/**\n * Moves to the next row whose label starts with what was typed in the last half-second. Repeating\n * one letter cycles through the rows starting with it.\n */\nfunction typeTo(char: string, index: number) {\n typed += normalizeText(char)\n typeahead.start(() => (typed = ''), 500)\n const rows = visible.value\n const needle = [...typed].every((c) => c === typed[0]) ? typed[0]! : typed\n const start = needle.length === 1 ? index + 1 : index\n for (let i = 0; i < rows.length; i++) {\n const node = rows[(start + i) % rows.length]!\n if (normalizeText(node.item.label).startsWith(needle)) return focusRow(node)\n }\n}\n\n// @keyboard @a11y\nfunction onKeydown(event: KeyboardEvent) {\n const tree = treeEl.value\n const value = rowValues.get(event.target as Element)\n if (!tree || value === undefined || event.altKey || event.ctrlKey || event.metaKey) return\n const rows = visible.value\n const index = rows.findIndex((node) => node.item.value === value)\n const node = rows[index]\n if (!node) return\n const { item } = node\n const isOpen = expandedSet.value.has(item.value)\n const forward = isRtl(tree) ? 'ArrowLeft' : 'ArrowRight'\n const back = isRtl(tree) ? 'ArrowRight' : 'ArrowLeft'\n\n switch (event.key) {\n case 'ArrowDown':\n focusRow(rows[index + 1])\n break\n case 'ArrowUp':\n focusRow(rows[index - 1])\n break\n case 'Home':\n focusRow(rows[0])\n break\n case 'End':\n focusRow(rows.at(-1))\n break\n case forward:\n if (!isBranch(item)) break\n if (!isOpen) {\n if (!item.disabled) setExpanded(item, true)\n } else if (rows[index + 1]?.parent?.item === item) focusRow(rows[index + 1])\n break\n case back:\n if (isBranch(item) && isOpen && !item.disabled) setExpanded(item, false)\n else focusRow(node.parent ?? undefined)\n break\n case 'Enter':\n // A link follows itself: the browser turns Enter into a click, which lands in onRowClick.\n if (item.href !== undefined) return\n if (!item.disabled) activate(node, event)\n break\n case ' ':\n // Space selects without following a link, as it does nothing on a link natively.\n if (item.disabled) break\n if (item.href !== undefined) applyDefault(node)\n else activate(node, event)\n break\n case '*': {\n const siblings = node.parent ? childrenOf(node.parent.item)! : props.items\n const closed = siblings\n .filter((sibling) => !sibling.disabled && isBranch(sibling))\n .map((sibling) => sibling.value)\n .filter((sibling) => !expandedSet.value.has(sibling))\n if (closed.length) expanded.value = [...expanded.value, ...closed]\n break\n }\n default:\n if (event.key.length !== 1 || event.key === ' ') return\n typeTo(event.key, index)\n }\n event.preventDefault()\n}\n\nfunction onRowClick(node: TreeNode, event: MouseEvent) {\n // An inert link has no address left to follow; this also covers a click on its children.\n if (node.item.disabled) {\n event.preventDefault()\n return\n }\n activate(node, event)\n}\n\n// The chevron folds without selecting, and without following the link it sits in.\nfunction onChevronClick(node: TreeNode, event: MouseEvent) {\n event.preventDefault()\n event.stopPropagation()\n if (!node.item.disabled && isBranch(node.item))\n setExpanded(node.item, !expandedSet.value.has(node.item.value))\n}\n\nfunction slotProps(node: TreeNode): TreeItemSlotProps {\n return {\n item: node.item,\n level: node.level,\n expanded: expandedSet.value.has(node.item.value) && !isPending(node.item),\n }\n}\n\nfunction ariaExpanded(item: TreeItem) {\n return isBranch(item) ? String(expandedSet.value.has(item.value)) : undefined\n}\n\nfunction ariaCurrent(item: TreeItem) {\n if (!item.current) return undefined\n return item.href !== undefined ? 'page' : 'true'\n}\n\n// The root is a wrapper holding the live region, which a tree may not contain: these two reach\n// the tree itself.\ndefineExpose({\n /** Moves the focus to the tree's tab stop: the last focused node, else the selected or current one. */\n focus: (options?: FocusOptions) =>\n tabStop.value !== undefined && rowEls.get(tabStop.value)?.focus(options),\n /** The `role=\"tree\"` element, which also receives the consumer's attributes. */\n el: treeEl,\n})\n</script>\n\n<template>\n <div class=\"v-tree-view\" :class=\"rootClass\" :style=\"rootStyle\">\n <ul\n ref=\"treeEl\"\n class=\"v-tree v-control\"\n role=\"tree\"\n :data-size=\"size\"\n :aria-multiselectable=\"selectionMode === 'multiple' || undefined\"\n v-bind=\"forwardedAttrs\"\n :aria-label=\"ariaLabel\"\n @keydown=\"onKeydown\"\n @focusin=\"onFocusin\"\n >\n <li v-for=\"node in visible\" :key=\"node.item.value\" class=\"v-tree-node\" role=\"none\">\n <component\n :is=\"node.item.href !== undefined ? 'a' : 'div'\"\n :ref=\"(el: Element | ComponentPublicInstance | null) => bindRow(node.item.value, el)\"\n class=\"v-tree-row\"\n role=\"treeitem\"\n :style=\"{ '--tree-level': node.level - 1 }\"\n :href=\"node.item.disabled ? undefined : node.item.href\"\n :tabindex=\"tabStop === node.item.value ? 0 : -1\"\n :aria-level=\"node.level\"\n :aria-setsize=\"node.setsize\"\n :aria-posinset=\"node.posinset\"\n :aria-expanded=\"ariaExpanded(node.item)\"\n :aria-selected=\"selectionMode === 'single' ? String(isSelected(node.item)) : undefined\"\n :aria-checked=\"\n selectionMode === 'multiple' ? checkStates.get(node.item.value) : undefined\n \"\n :aria-current=\"ariaCurrent(node.item)\"\n :aria-disabled=\"node.item.disabled || undefined\"\n :aria-busy=\"loading.has(node.item.value) || undefined\"\n :data-current=\"node.item.current ? '' : undefined\"\n @click=\"onRowClick(node, $event)\"\n >\n <!-- Decorative: the row is the control, and the arrows fold it. -->\n <span class=\"v-tree-toggle\" aria-hidden=\"true\" @click=\"onChevronClick(node, $event)\">\n <VSpinner v-if=\"loading.has(node.item.value)\" />\n <VIcon\n v-else-if=\"isBranch(node.item)\"\n class=\"v-tree-chevron\"\n :name=\"chevronRightIcon\"\n mirrored\n />\n </span>\n <VCheckMark v-if=\"selectionMode === 'multiple'\" />\n <slot name=\"icon\" v-bind=\"slotProps(node)\">\n <VIcon v-if=\"node.item.icon\" class=\"v-tree-icon\" v-bind=\"iconProps(node.item.icon)\" />\n </slot>\n <span class=\"v-tree-label\"\n ><slot name=\"label\" v-bind=\"slotProps(node)\">{{ node.item.label }}</slot></span\n >\n <template v-if=\"failed.has(node.item.value)\">\n <VIcon class=\"v-tree-error\" :name=\"errorIcon\" />\n <span class=\"v-visually-hidden\">{{ m.treeView.loadFailed }}</span>\n </template>\n <span v-if=\"$slots.end\" class=\"v-tree-end\"\n ><slot name=\"end\" v-bind=\"slotProps(node)\"\n /></span>\n </component>\n </li>\n </ul>\n <div class=\"v-visually-hidden\" role=\"status\" aria-live=\"polite\">{{ announcement }}</div>\n </div>\n</template>\n\n<style>\n@layer vectis.components {\n .v-tree {\n /* One level of indentation puts a child's chevron under its parent's icon. */\n --tree-indent: calc(var(--vectis-icon-size) + var(--control-gap));\n\n display: flex;\n flex-direction: column;\n margin: 0;\n padding: 0;\n list-style: none;\n font-family: var(--vectis-text-family);\n }\n\n .v-tree-row {\n /* See VSideNavigation: the radius cannot exceed half the height of a single row. */\n --tree-row-radius: min(var(--vectis-radius-interactive), calc(var(--control-height) / 2));\n\n display: flex;\n align-items: center;\n gap: var(--control-gap);\n min-block-size: var(--control-height);\n padding-block: var(--vectis-space-1);\n padding-inline: calc(var(--vectis-space-2) + var(--tree-level, 0) * var(--tree-indent))\n var(--vectis-space-2);\n border-radius: var(--tree-row-radius);\n color: var(--vectis-color-text);\n font-size: var(--control-font-size);\n font-weight: var(--vectis-text-body-md-weight);\n line-height: var(--vectis-text-body-md-leading);\n text-decoration: none;\n cursor: pointer;\n user-select: none;\n }\n\n .v-tree-row:hover:not([aria-disabled='true']) {\n background: var(--vectis-color-surface-muted);\n }\n\n /* Drawn inwards, so that neighbouring rows cannot cover it. */\n .v-tree-row:focus-visible {\n outline: var(--vectis-focus-ring-width) solid var(--vectis-focus-ring-color);\n outline-offset: calc(-1 * var(--vectis-focus-ring-width));\n }\n\n .v-tree-row:is([aria-selected='true'], [data-current]) {\n background: var(--vectis-color-accent-surface);\n color: var(--vectis-color-accent-text);\n }\n\n /* The selected row is already tinted, so its hover deepens that tint; the VMenuItem idiom. */\n .v-tree-row:is([aria-selected='true'], [data-current]):hover:not([aria-disabled='true']) {\n background: color-mix(\n in oklab,\n var(--vectis-color-accent-surface),\n var(--vectis-color-accent-text) 8%\n );\n }\n\n .v-tree-row:is([aria-selected='true'], [data-current]) :is(.v-tree-icon, .v-tree-toggle) {\n color: inherit;\n }\n\n .v-tree-row[data-current] .v-tree-label {\n font-weight: var(--vectis-text-label-weight);\n }\n\n .v-tree-toggle {\n display: inline-grid;\n flex: none;\n place-items: center;\n inline-size: var(--vectis-icon-size);\n block-size: var(--vectis-icon-size);\n color: var(--vectis-color-text-muted);\n }\n\n .v-tree-chevron {\n transition: rotate var(--vectis-duration-base) var(--vectis-ease-default);\n }\n\n .v-tree-row[aria-expanded='true'] .v-tree-chevron {\n rotate: 90deg;\n }\n\n /* The mirrored chevron points left, and turns the other way to point down. */\n .v-tree-row[aria-expanded='true'] .v-tree-chevron:dir(rtl) {\n rotate: -90deg;\n }\n\n .v-tree-icon {\n flex: none;\n color: var(--vectis-color-text-muted);\n }\n\n .v-tree-label {\n flex: 1;\n min-inline-size: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .v-tree-end {\n flex: none;\n display: flex;\n align-items: center;\n gap: var(--vectis-space-1);\n color: var(--vectis-color-text-muted);\n }\n\n .v-tree-error {\n flex: none;\n color: var(--vectis-color-danger-text);\n }\n\n .v-tree-row:is([aria-checked='true'], [aria-checked='mixed']) .v-check-mark {\n background: var(--vectis-color-accent);\n border-color: var(--vectis-color-accent);\n }\n\n .v-tree-row[aria-checked='true'] .v-check-mark-tick,\n .v-tree-row[aria-checked='mixed'] .v-check-mark-dash {\n opacity: 1;\n }\n\n /* Last of the colour rules, so that a disabled node reads as such whatever its state. */\n .v-tree-row[aria-disabled='true'] {\n color: var(--vectis-color-text-subtle);\n cursor: not-allowed;\n }\n\n .v-tree-row[aria-disabled='true'] :is(.v-tree-icon, .v-tree-toggle, .v-tree-end) {\n color: inherit;\n }\n\n .v-tree-row[aria-disabled='true'] .v-check-mark {\n background: var(--vectis-color-surface-muted);\n border-color: var(--vectis-color-border);\n color: var(--vectis-color-text-subtle);\n }\n\n /*\n * Forced colours flatten the tint of the selected row: it takes the system selection pair,\n * repeated to (0,4,0) above its own hover.\n */\n @media (forced-colors: active) {\n .v-tree-row.v-tree-row:is([aria-selected='true'], [data-current]):not([aria-disabled='true']) {\n forced-color-adjust: none;\n background: Highlight;\n color: HighlightText;\n }\n\n .v-tree-row:is([aria-checked='true'], [aria-checked='mixed']):not([aria-disabled='true'])\n .v-check-mark {\n forced-color-adjust: none;\n background: Highlight;\n border-color: Highlight;\n color: HighlightText;\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .v-tree-chevron {\n transition: none;\n }\n }\n}\n</style>\n"],"mappings":""}
|
|
1
|
+
{"version":3,"file":"VTreeView.js","names":[],"sources":["../../../src/components/VTreeView/VTreeView.vue"],"sourcesContent":["<script setup lang=\"ts\">\n// @keyboard @a11y @core\n/**\n * A hierarchy following the ARIA tree pattern. The rows are rendered flat, in display order, their\n * depth carried by `aria-level`, `aria-setsize` and `aria-posinset`: no native element folds a tree\n * with a single tab stop, and a row that does not wrap its subtree keeps its accessible name free of\n * its descendants' text. JavaScript owns the focus, the keyboard, selection and lazy loading.\n */\nimport { computed, onMounted, ref, shallowReactive, watch } from 'vue'\nimport type { ComponentPublicInstance } from 'vue'\n\nimport VCheckMark from '../VCheckbox/VCheckMark.vue'\nimport VIcon from '../VIcon/VIcon.vue'\nimport VSpinner from '../VSpinner/VSpinner.vue'\nimport { iconProps } from '../VIcon/iconProps'\nimport { chevron_right as chevronRightIcon } from '../VIcon/icons/chevron_right'\nimport { error as errorIcon } from '../VIcon/icons/error'\nimport type { IconSource } from '../VIcon/types'\nimport type { ItemValue } from '../../types'\nimport { useAriaLabel } from '../../composables/useAriaLabel'\nimport { useLiveAnnouncer } from '../../composables/useLiveAnnouncer'\nimport { useRootAttrs } from '../../composables/useRootAttrs'\nimport { useTypeahead } from '../../composables/useTypeahead'\nimport { useMessages } from '../../i18n/state'\nimport { isRtl } from '../../utils/direction'\nimport { normalizeText } from '../../utils/text'\n\n/** Whether nodes can be selected: not at all, one at a time, or several with checkboxes. */\nexport type TreeSelectionMode = 'none' | 'single' | 'multiple'\n\n/** The height of the rows: 32 or 40 pixels. */\nexport type TreeViewSize = 'sm' | 'md'\n\n/**\n * What is selected, and its shape follows `selectionMode`: a single value (or `null`) in `single`\n * mode, an array in `multiple` mode.\n */\nexport type TreeViewModelValue = ItemValue | ItemValue[] | null\n\n/** One node of the tree. */\nexport interface TreeItem {\n /** What identifies the node, in the selection and expansion models. Unique across the tree. */\n value: ItemValue\n /** What the row says. It is also what typeahead matches. */\n label: string\n /** An icon before the label: an icon name, or an explicit render. */\n icon?: IconSource\n /** The child nodes, which make this node a branch. */\n children?: TreeItem[]\n /**\n * Marks a branch whose children `loadChildren` fetches on its first expansion. Ignored when\n * `children` is set or when the tree has no `loadChildren`.\n */\n lazy?: boolean\n /** Where the node leads, which makes the row a link. */\n href?: string\n /** Marks the node as the page being viewed (`aria-current`). */\n current?: boolean\n /**\n * Keeps the node focusable, so it is still found, but it cannot be selected, activated, expanded\n * or collapsed.\n */\n disabled?: boolean\n}\n\n/** What the `#icon`, `#label` and `#end` slots receive. */\nexport interface TreeItemSlotProps {\n item: TreeItem\n /** The depth of the node, from 1. */\n level: number\n /** Whether the node's children are shown. */\n expanded: boolean\n}\n\ntype CheckState = 'true' | 'false' | 'mixed'\n\ninterface TreeNode {\n item: TreeItem\n level: number\n parent: TreeNode | null\n posinset: number\n setsize: number\n}\n\ninterface TreeViewProps {\n /** The first level of the tree. */\n items: TreeItem[]\n /**\n * Whether nodes can be selected. In `multiple` mode each row has a checkbox, and checking a\n * branch checks everything under it.\n */\n selectionMode?: TreeSelectionMode\n /**\n * Fetches the children of a `lazy` node, on its first expansion. The tree keeps them; a failure\n * collapses the node again, and the next expansion retries.\n */\n loadChildren?: (item: TreeItem) => Promise<TreeItem[]>\n /** The height of the rows, 32 or 40 pixels. */\n size?: TreeViewSize\n /** What screen readers announce for the tree. It falls back to the design system dictionary. */\n label?: string\n}\n\ndefineOptions({ inheritAttrs: false })\n\nconst props = withDefaults(defineProps<TreeViewProps>(), {\n selectionMode: 'none',\n loadChildren: undefined,\n size: 'md',\n label: undefined,\n})\n\n/**\n * What is selected. In `single` mode, a value or `null`; in `multiple` mode, the checked nodes in\n * tree order, a branch included when everything under it is checked. A value given for a branch\n * checks its whole subtree. A shape that does not match the mode reads as an empty selection.\n */\nconst model = defineModel<TreeViewModelValue>({ default: null })\n\n/** The values of the expanded nodes. Left unbound, the tree keeps that state itself. */\nconst expanded = defineModel<ItemValue[]>('expanded', { default: () => [] })\n\nconst emit = defineEmits<{\n /**\n * A node was clicked or Enter was pressed on it, after the tree applied its own effect: selecting\n * it, checking it, or folding a branch in `none` mode. For a link, calling `preventDefault()` on\n * the event stops the navigation, which hands it to a router.\n */\n activate: [item: TreeItem, event: MouseEvent | KeyboardEvent]\n}>()\n\ndefineSlots<{\n /** Replaces the icon of a node, for instance to show an open folder when it is expanded. */\n icon?(props: TreeItemSlotProps): unknown\n /** Replaces the label of a node. */\n label?(props: TreeItemSlotProps): unknown\n /**\n * Content at the end of a row: a count, a badge. It must not be focusable: a row is a single\n * control.\n */\n end?(props: TreeItemSlotProps): unknown\n}>()\n\nconst { rootClass, rootStyle, forwardedAttrs } = useRootAttrs()\nconst m = useMessages()\nconst ariaLabel = useAriaLabel(() => props.label ?? m.value.treeView.label)\nconst { polite: announcement, announce } = useLiveAnnouncer()\n\nconst loaded = shallowReactive(new Map<ItemValue, TreeItem[]>())\nconst loading = shallowReactive(new Set<ItemValue>())\nconst failed = shallowReactive(new Set<ItemValue>())\n\nfunction childrenOf(item: TreeItem): TreeItem[] | undefined {\n return item.children ?? loaded.get(item.value)\n}\n\nfunction isPending(item: TreeItem): boolean {\n return !!props.loadChildren && !!item.lazy && !item.children && !loaded.has(item.value)\n}\n\nfunction isBranch(item: TreeItem): boolean {\n return !!childrenOf(item)?.length || isPending(item)\n}\n\nconst expandedSet = computed(() => new Set(expanded.value))\n\n/** The rows on screen, in display order: every node whose ancestors are all expanded. */\nconst visible = computed(() => {\n const rows: TreeNode[] = []\n const walk = (items: TreeItem[], level: number, parent: TreeNode | null) => {\n items.forEach((item, index) => {\n const node = { item, level, parent, posinset: index + 1, setsize: items.length }\n rows.push(node)\n const children = childrenOf(item)\n if (children && expandedSet.value.has(item.value)) walk(children, level + 1, node)\n })\n }\n walk(props.items, 1, null)\n return rows\n})\n\nfunction setExpanded(item: TreeItem, open: boolean) {\n if (open === expandedSet.value.has(item.value)) return\n expanded.value = open\n ? [...expanded.value, item.value]\n : expanded.value.filter((value) => value !== item.value)\n}\n\n// @core\n/**\n * Derives what each checkbox shows. A value in the selection checks its node and the whole subtree\n * under it; a branch then shows checked when all its children are, unchecked when none are, and\n * mixed otherwise, so its own presence in the selection only matters through its children. The map\n * is filled in tree order, which is the order the model is written in.\n */\nfunction deriveCheckStates(chosen: Set<ItemValue>): Map<ItemValue, CheckState> {\n const states = new Map<ItemValue, CheckState>()\n const visit = (item: TreeItem, inherited: boolean): CheckState => {\n states.set(item.value, 'false')\n const own = inherited || chosen.has(item.value)\n const children = childrenOf(item)\n let state: CheckState = own ? 'true' : 'false'\n if (children?.length) {\n const childStates = children.map((child) => visit(child, own))\n state = childStates.every((s) => s === 'true')\n ? 'true'\n : childStates.every((s) => s === 'false')\n ? 'false'\n : 'mixed'\n }\n states.set(item.value, state)\n return state\n }\n props.items.forEach((item) => visit(item, false))\n return states\n}\n\nconst chosenValues = computed<ItemValue[]>(() => {\n const value = model.value\n if (props.selectionMode === 'multiple') return Array.isArray(value) ? value : []\n return value === null || Array.isArray(value) ? [] : [value]\n})\n\nconst checkStates = computed(() =>\n props.selectionMode === 'multiple'\n ? deriveCheckStates(new Set(chosenValues.value))\n : new Map<ItemValue, CheckState>(),\n)\n\n/** Calls `fn` on a node and on every node under it that is not disabled. */\nfunction eachEnabled(item: TreeItem, fn: (item: TreeItem) => void) {\n fn(item)\n childrenOf(item)?.forEach((child) => {\n if (!child.disabled) eachEnabled(child, fn)\n })\n}\n\n// @core\n/**\n * Checks a node and its subtree when any leaf of it is unchecked, and unchecks them otherwise.\n * Disabled nodes keep their state. Unchecking also drops the ancestors, which would otherwise check\n * the node again through their subtree. Values the tree does not know, such as the children of a\n * node not loaded yet, are kept at the end.\n */\nfunction toggleCheck(node: TreeNode) {\n const states = checkStates.value\n const chosen = new Set<ItemValue>()\n states.forEach((state, value) => {\n if (state === 'true') chosen.add(value)\n })\n const unknown = chosenValues.value.filter((value) => !states.has(value))\n\n let check = false\n eachEnabled(node.item, (item) => {\n if (!childrenOf(item)?.length && states.get(item.value) !== 'true') check = true\n })\n // Only the leaves are added: a branch in the selection would check its disabled children too.\n eachEnabled(node.item, (item) => {\n if (!check) chosen.delete(item.value)\n else if (!childrenOf(item)?.length) chosen.add(item.value)\n })\n if (!check) for (let p = node.parent; p; p = p.parent) chosen.delete(p.item.value)\n\n const next: ItemValue[] = []\n deriveCheckStates(chosen).forEach((state, value) => {\n if (state === 'true') next.push(value)\n })\n model.value = [...next, ...unknown]\n}\n\nfunction isSelected(item: TreeItem): boolean {\n return props.selectionMode === 'single' && chosenValues.value[0] === item.value\n}\n\n// @core\n/** What a click or Enter does besides the `activate` event, which follows it. */\nfunction applyDefault(node: TreeNode) {\n const { item } = node\n if (props.selectionMode === 'single') model.value = item.value\n else if (props.selectionMode === 'multiple') toggleCheck(node)\n else if (item.href === undefined && isBranch(item))\n setExpanded(item, !expandedSet.value.has(item.value))\n}\n\nfunction activate(node: TreeNode, event: MouseEvent | KeyboardEvent) {\n applyDefault(node)\n emit('activate', node.item, event)\n}\n\nasync function load(item: TreeItem) {\n const loader = props.loadChildren\n if (!loader) return\n loading.add(item.value)\n failed.delete(item.value)\n try {\n loaded.set(item.value, await loader(item))\n } catch {\n failed.add(item.value)\n setExpanded(item, false)\n announce(m.value.treeView.loadError(item.label), false)\n } finally {\n loading.delete(item.value)\n }\n}\n\n// @ssr\n// Loading waits for the client: an expanded lazy node renders busy on the server, then fetches.\nonMounted(() => {\n // The expanded set is watched too: a pending node shows no children, so its expansion alone\n // does not change the rows.\n watch(\n [visible, expandedSet],\n ([rows, open]) => {\n for (const { item } of rows)\n if (open.has(item.value) && isPending(item) && !loading.has(item.value)) void load(item)\n },\n { immediate: true },\n )\n})\n\nconst treeEl = ref<HTMLElement | null>(null)\nconst rowEls = new Map<ItemValue, HTMLElement>()\nconst rowValues = new WeakMap<Element, ItemValue>()\n\nfunction bindRow(value: ItemValue, el: Element | ComponentPublicInstance | null) {\n if (el instanceof HTMLElement) {\n rowEls.set(value, el)\n rowValues.set(el, value)\n } else rowEls.delete(value)\n}\n\n// @keyboard @a11y\n/**\n * The single tab stop: the node last focused, or its closest ancestor still on screen once a\n * branch above it folds; before any focus, the first selected node on screen, then the current\n * one, then the first row.\n */\nconst active = ref<TreeNode | null>(null)\nconst tabStop = computed<ItemValue | undefined>(() => {\n const rows = visible.value\n const shown = new Set(rows.map((node) => node.item.value))\n for (let node = active.value; node; node = node.parent)\n if (shown.has(node.item.value)) return node.item.value\n const selected =\n props.selectionMode === 'multiple'\n ? rows.find((node) => checkStates.value.get(node.item.value) === 'true')\n : rows.find((node) => isSelected(node.item))\n return (selected ?? rows.find((node) => node.item.current) ?? rows[0])?.item.value\n})\n\nfunction onFocusin(event: FocusEvent) {\n const value = rowValues.get(event.target as Element)\n if (value === undefined) return\n active.value = visible.value.find((node) => node.item.value === value) ?? null\n}\n\nfunction focusRow(node: TreeNode | undefined) {\n if (node) rowEls.get(node.item.value)?.focus()\n}\n\nconst { find: findTyped } = useTypeahead()\n\n// @keyboard\n// Moves to the row whose label starts with what was typed. Disabled rows stay focusable in a\n// tree, so they are matched too.\nfunction typeTo(char: string, index: number) {\n const rows = visible.value\n focusRow(rows[findTyped(char, rows.length, index, (i) => normalizeText(rows[i]!.item.label))])\n}\n\n// @keyboard @a11y\nfunction onKeydown(event: KeyboardEvent) {\n const tree = treeEl.value\n const value = rowValues.get(event.target as Element)\n if (!tree || value === undefined || event.altKey || event.ctrlKey || event.metaKey) return\n const rows = visible.value\n const index = rows.findIndex((node) => node.item.value === value)\n const node = rows[index]\n if (!node) return\n const { item } = node\n const isOpen = expandedSet.value.has(item.value)\n const forward = isRtl(tree) ? 'ArrowLeft' : 'ArrowRight'\n const back = isRtl(tree) ? 'ArrowRight' : 'ArrowLeft'\n\n switch (event.key) {\n case 'ArrowDown':\n focusRow(rows[index + 1])\n break\n case 'ArrowUp':\n focusRow(rows[index - 1])\n break\n case 'Home':\n focusRow(rows[0])\n break\n case 'End':\n focusRow(rows.at(-1))\n break\n case forward:\n if (!isBranch(item)) break\n if (!isOpen) {\n if (!item.disabled) setExpanded(item, true)\n } else if (rows[index + 1]?.parent?.item === item) focusRow(rows[index + 1])\n break\n case back:\n if (isBranch(item) && isOpen && !item.disabled) setExpanded(item, false)\n else focusRow(node.parent ?? undefined)\n break\n case 'Enter':\n // A link follows itself: the browser turns Enter into a click, which lands in onRowClick.\n if (item.href !== undefined) return\n if (!item.disabled) activate(node, event)\n break\n case ' ':\n // Space selects without following a link, as it does nothing on a link natively.\n if (item.disabled) break\n if (item.href !== undefined) applyDefault(node)\n else activate(node, event)\n break\n case '*': {\n const siblings = node.parent ? childrenOf(node.parent.item)! : props.items\n const closed = siblings\n .filter((sibling) => !sibling.disabled && isBranch(sibling))\n .map((sibling) => sibling.value)\n .filter((sibling) => !expandedSet.value.has(sibling))\n if (closed.length) expanded.value = [...expanded.value, ...closed]\n break\n }\n default:\n if (event.key.length !== 1 || event.key === ' ') return\n typeTo(event.key, index)\n }\n event.preventDefault()\n}\n\nfunction onRowClick(node: TreeNode, event: MouseEvent) {\n // An inert link has no address left to follow; this also covers a click on its children.\n if (node.item.disabled) {\n event.preventDefault()\n return\n }\n activate(node, event)\n}\n\n// The chevron folds without selecting, and without following the link it sits in.\nfunction onChevronClick(node: TreeNode, event: MouseEvent) {\n event.preventDefault()\n event.stopPropagation()\n if (!node.item.disabled && isBranch(node.item))\n setExpanded(node.item, !expandedSet.value.has(node.item.value))\n}\n\nfunction slotProps(node: TreeNode): TreeItemSlotProps {\n return {\n item: node.item,\n level: node.level,\n expanded: expandedSet.value.has(node.item.value) && !isPending(node.item),\n }\n}\n\nfunction ariaExpanded(item: TreeItem) {\n return isBranch(item) ? String(expandedSet.value.has(item.value)) : undefined\n}\n\nfunction ariaCurrent(item: TreeItem) {\n if (!item.current) return undefined\n return item.href !== undefined ? 'page' : 'true'\n}\n\n// The root is a wrapper holding the live region, which a tree may not contain: these two reach\n// the tree itself.\ndefineExpose({\n /** Moves the focus to the tree's tab stop: the last focused node, else the selected or current one. */\n focus: (options?: FocusOptions) =>\n tabStop.value !== undefined && rowEls.get(tabStop.value)?.focus(options),\n /** The `role=\"tree\"` element, which also receives the consumer's attributes. */\n el: treeEl,\n})\n</script>\n\n<template>\n <div class=\"v-tree-view\" :class=\"rootClass\" :style=\"rootStyle\">\n <ul\n ref=\"treeEl\"\n class=\"v-tree v-control\"\n role=\"tree\"\n :data-size=\"size\"\n :aria-multiselectable=\"selectionMode === 'multiple' || undefined\"\n v-bind=\"forwardedAttrs\"\n :aria-label=\"ariaLabel\"\n @keydown=\"onKeydown\"\n @focusin=\"onFocusin\"\n >\n <li v-for=\"node in visible\" :key=\"node.item.value\" class=\"v-tree-node\" role=\"none\">\n <component\n :is=\"node.item.href !== undefined ? 'a' : 'div'\"\n :ref=\"(el: Element | ComponentPublicInstance | null) => bindRow(node.item.value, el)\"\n class=\"v-tree-row\"\n role=\"treeitem\"\n :style=\"{ '--tree-level': node.level - 1 }\"\n :href=\"node.item.disabled ? undefined : node.item.href\"\n :tabindex=\"tabStop === node.item.value ? 0 : -1\"\n :aria-level=\"node.level\"\n :aria-setsize=\"node.setsize\"\n :aria-posinset=\"node.posinset\"\n :aria-expanded=\"ariaExpanded(node.item)\"\n :aria-selected=\"selectionMode === 'single' ? String(isSelected(node.item)) : undefined\"\n :aria-checked=\"\n selectionMode === 'multiple' ? checkStates.get(node.item.value) : undefined\n \"\n :aria-current=\"ariaCurrent(node.item)\"\n :aria-disabled=\"node.item.disabled || undefined\"\n :aria-busy=\"loading.has(node.item.value) || undefined\"\n :data-current=\"node.item.current ? '' : undefined\"\n @click=\"onRowClick(node, $event)\"\n >\n <!-- Decorative: the row is the control, and the arrows fold it. -->\n <span class=\"v-tree-toggle\" aria-hidden=\"true\" @click=\"onChevronClick(node, $event)\">\n <VSpinner v-if=\"loading.has(node.item.value)\" />\n <VIcon\n v-else-if=\"isBranch(node.item)\"\n class=\"v-tree-chevron\"\n :name=\"chevronRightIcon\"\n mirrored\n />\n </span>\n <VCheckMark v-if=\"selectionMode === 'multiple'\" />\n <slot name=\"icon\" v-bind=\"slotProps(node)\">\n <VIcon v-if=\"node.item.icon\" class=\"v-tree-icon\" v-bind=\"iconProps(node.item.icon)\" />\n </slot>\n <span class=\"v-tree-label\"\n ><slot name=\"label\" v-bind=\"slotProps(node)\">{{ node.item.label }}</slot></span\n >\n <template v-if=\"failed.has(node.item.value)\">\n <VIcon class=\"v-tree-error\" :name=\"errorIcon\" />\n <span class=\"v-visually-hidden\">{{ m.treeView.loadFailed }}</span>\n </template>\n <span v-if=\"$slots.end\" class=\"v-tree-end\"\n ><slot name=\"end\" v-bind=\"slotProps(node)\"\n /></span>\n </component>\n </li>\n </ul>\n <div class=\"v-visually-hidden\" role=\"status\" aria-live=\"polite\">{{ announcement }}</div>\n </div>\n</template>\n\n<style>\n@layer vectis.components {\n .v-tree {\n /* One level of indentation puts a child's chevron under its parent's icon. */\n --tree-indent: calc(var(--vectis-icon-size) + var(--control-gap));\n\n display: flex;\n flex-direction: column;\n margin: 0;\n padding: 0;\n list-style: none;\n font-family: var(--vectis-text-family);\n }\n\n .v-tree-row {\n /* See VSideNavigation: the radius cannot exceed half the height of a single row. */\n --tree-row-radius: min(var(--vectis-radius-interactive), calc(var(--control-height) / 2));\n\n display: flex;\n align-items: center;\n gap: var(--control-gap);\n min-block-size: var(--control-height);\n padding-block: var(--vectis-space-1);\n padding-inline: calc(var(--vectis-space-2) + var(--tree-level, 0) * var(--tree-indent))\n var(--vectis-space-2);\n border-radius: var(--tree-row-radius);\n color: var(--vectis-color-text);\n font-size: var(--control-font-size);\n font-weight: var(--vectis-text-body-md-weight);\n line-height: var(--vectis-text-body-md-leading);\n text-decoration: none;\n cursor: pointer;\n user-select: none;\n }\n\n .v-tree-row:hover:not([aria-disabled='true']) {\n background: var(--vectis-color-surface-muted);\n }\n\n /* Drawn inwards, so that neighbouring rows cannot cover it. */\n .v-tree-row:focus-visible {\n outline: var(--vectis-focus-ring-width) solid var(--vectis-focus-ring-color);\n outline-offset: calc(-1 * var(--vectis-focus-ring-width));\n }\n\n .v-tree-row:is([aria-selected='true'], [data-current]) {\n background: var(--vectis-color-accent-surface);\n color: var(--vectis-color-accent-text);\n }\n\n /* The selected row is already tinted, so its hover deepens that tint; the VMenuItem idiom. */\n .v-tree-row:is([aria-selected='true'], [data-current]):hover:not([aria-disabled='true']) {\n background: color-mix(\n in oklab,\n var(--vectis-color-accent-surface),\n var(--vectis-color-accent-text) 8%\n );\n }\n\n .v-tree-row:is([aria-selected='true'], [data-current]) :is(.v-tree-icon, .v-tree-toggle) {\n color: inherit;\n }\n\n .v-tree-row[data-current] .v-tree-label {\n font-weight: var(--vectis-text-label-weight);\n }\n\n .v-tree-toggle {\n display: inline-grid;\n flex: none;\n place-items: center;\n inline-size: var(--vectis-icon-size);\n block-size: var(--vectis-icon-size);\n color: var(--vectis-color-text-muted);\n }\n\n .v-tree-chevron {\n transition: rotate var(--vectis-duration-base) var(--vectis-ease-default);\n }\n\n .v-tree-row[aria-expanded='true'] .v-tree-chevron {\n rotate: 90deg;\n }\n\n /* The mirrored chevron points left, and turns the other way to point down. */\n .v-tree-row[aria-expanded='true'] .v-tree-chevron:dir(rtl) {\n rotate: -90deg;\n }\n\n .v-tree-icon {\n flex: none;\n color: var(--vectis-color-text-muted);\n }\n\n .v-tree-label {\n flex: 1;\n min-inline-size: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .v-tree-end {\n flex: none;\n display: flex;\n align-items: center;\n gap: var(--vectis-space-1);\n color: var(--vectis-color-text-muted);\n }\n\n .v-tree-error {\n flex: none;\n color: var(--vectis-color-danger-text);\n }\n\n .v-tree-row:is([aria-checked='true'], [aria-checked='mixed']) .v-check-mark {\n background: var(--vectis-color-accent);\n border-color: var(--vectis-color-accent);\n }\n\n .v-tree-row[aria-checked='true'] .v-check-mark-tick,\n .v-tree-row[aria-checked='mixed'] .v-check-mark-dash {\n opacity: 1;\n }\n\n /* Last of the colour rules, so that a disabled node reads as such whatever its state. */\n .v-tree-row[aria-disabled='true'] {\n color: var(--vectis-color-text-subtle);\n cursor: not-allowed;\n }\n\n .v-tree-row[aria-disabled='true'] :is(.v-tree-icon, .v-tree-toggle, .v-tree-end) {\n color: inherit;\n }\n\n .v-tree-row[aria-disabled='true'] .v-check-mark {\n background: var(--vectis-color-surface-muted);\n border-color: var(--vectis-color-border);\n color: var(--vectis-color-text-subtle);\n }\n\n /*\n * Forced colours flatten the tint of the selected row: it takes the system selection pair,\n * repeated to (0,4,0) above its own hover.\n */\n @media (forced-colors: active) {\n .v-tree-row.v-tree-row:is([aria-selected='true'], [data-current]):not([aria-disabled='true']) {\n forced-color-adjust: none;\n background: Highlight;\n color: HighlightText;\n }\n\n .v-tree-row:is([aria-checked='true'], [aria-checked='mixed']):not([aria-disabled='true'])\n .v-check-mark {\n forced-color-adjust: none;\n background: Highlight;\n border-color: Highlight;\n color: HighlightText;\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .v-tree-chevron {\n transition: none;\n }\n }\n}\n</style>\n"],"mappings":""}
|