vuetify
Version:
Vue Material Component Framework
432 lines (419 loc) • 14.8 kB
JavaScript
import { createVNode as _createVNode, normalizeClass as _normalizeClass, normalizeStyle as _normalizeStyle, createElementVNode as _createElementVNode, createTextVNode as _createTextVNode, Fragment as _Fragment } from "vue";
/**
* VHotkey Component
*
* Purpose: Renders keyboard shortcuts in a visually consistent and accessible way.
* This component handles the complex logic of displaying keyboard combinations
* across different platforms (Mac vs PC) and display modes (icons, symbols, text).
*
* Why it exists:
* - Provides consistent visual representation of keyboard shortcuts
* - Handles platform-specific key differences (Cmd vs Ctrl, Option vs Alt)
* - Supports multiple display modes for different design needs
* - Encapsulates complex key parsing and rendering logic
* - Used throughout the command palette for instruction display
*
* Key Mapping Structure:
* The keyMap uses a simple object structure where each key has:
* - `default`: Required configuration for all platforms
* - `mac`: Optional Mac-specific overrides
* Each config can specify `symbol`, `icon`, and `text` representations.
*
* Example:
* ```
* ctrl: {
* mac: { symbol: '⌃', icon: '$ctrl', text: 'Control' },
* default: { text: 'Ctrl', icon: '$ctrl' }
* }
* ```
*/
// Styles
import "./VHotkey.css";
// Components
import { VIcon } from "../../components/VIcon/index.js";
import { VKbd } from "../../components/VKbd/index.js"; // Composables
import { makeBorderProps, useBorder } from "../../composables/border.js";
import { makeComponentProps } from "../../composables/component.js";
import { makeElevationProps, useElevation } from "../../composables/elevation.js";
import { splitKeyCombination, splitKeySequence } from "../../composables/hotkey/hotkey-parsing.js";
import { useLocale, useRtl } from "../../composables/locale.js";
import { makeRoundedProps, useRounded } from "../../composables/rounded.js";
import { makeThemeProps, provideTheme } from "../../composables/theme.js";
import { useVariant } from "../../composables/variant.js"; // Utilities
import { computed } from 'vue';
import { genericComponent, mergeDeep, propsFactory, useRender } from "../../util/index.js"; // Types
// Display mode types for different visual representations
// Extended variant type that includes our custom 'contained' variant
// Key display tuple: [mode, content] where content is string or IconValue
// Key tuple: [mode, content] where content is string or IconValue
function processKey(config, requestedMode, isMac) {
const keyCfg = isMac && config.mac ? config.mac : config.default;
// 1. Resolve the safest display mode for the current platform
const mode = (() => {
// Non-Mac platforms rarely use icons – prefer text
if (requestedMode === 'icon' && !isMac) return 'text';
// If the requested mode lacks an asset, fall back to text
if (requestedMode === 'icon' && !keyCfg.icon) return 'text';
if (requestedMode === 'symbol' && !keyCfg.symbol) return 'text';
return requestedMode;
})();
// 2. Pick value for the chosen mode, defaulting to text representation
let value = keyCfg[mode] ?? keyCfg.text;
// 3. Guard against icon tokens leaking into text mode (e.g. "$ctrl")
if (mode === 'text' && typeof value === 'string' && value.startsWith('$') && !value.startsWith('$vuetify.')) {
value = value.slice(1).toUpperCase(); // "$ctrl" → "CTRL"
}
return mode === 'icon' ? ['icon', value] : [mode, value];
}
export const hotkeyMap = {
ctrl: {
mac: {
symbol: '⌃',
icon: '$ctrl',
text: '$vuetify.hotkey.ctrl'
},
default: {
text: 'Ctrl'
}
},
meta: {
mac: {
symbol: '⌘',
icon: '$command',
text: '$vuetify.hotkey.command'
},
default: {
text: 'Ctrl'
}
},
cmd: {
mac: {
symbol: '⌘',
icon: '$command',
text: '$vuetify.hotkey.command'
},
default: {
text: 'Ctrl'
}
},
shift: {
mac: {
symbol: '⇧',
icon: '$shift',
text: '$vuetify.hotkey.shift'
},
default: {
text: 'Shift'
}
},
alt: {
mac: {
symbol: '⌥',
icon: '$alt',
text: '$vuetify.hotkey.option'
},
default: {
text: 'Alt'
}
},
enter: {
default: {
symbol: '↵',
icon: '$enter',
text: '$vuetify.hotkey.enter'
}
},
arrowup: {
default: {
symbol: '↑',
icon: '$arrowup',
text: '$vuetify.hotkey.upArrow'
}
},
arrowdown: {
default: {
symbol: '↓',
icon: '$arrowdown',
text: '$vuetify.hotkey.downArrow'
}
},
arrowleft: {
default: {
symbol: '←',
icon: '$arrowleft',
text: '$vuetify.hotkey.leftArrow'
}
},
arrowright: {
default: {
symbol: '→',
icon: '$arrowright',
text: '$vuetify.hotkey.rightArrow'
}
},
backspace: {
default: {
symbol: '⌫',
icon: '$backspace',
text: '$vuetify.hotkey.backspace'
}
},
escape: {
default: {
text: '$vuetify.hotkey.escape'
}
},
' ': {
mac: {
symbol: '␣',
icon: '$space',
text: '$vuetify.hotkey.space'
},
default: {
text: '$vuetify.hotkey.space'
}
},
'-': {
default: {
text: '-'
}
}
};
// Create custom variant props that extend the base variant props with our 'contained' option
const makeVHotkeyVariantProps = propsFactory({
variant: {
type: String,
default: 'elevated',
validator: v => ['elevated', 'flat', 'tonal', 'outlined', 'text', 'plain', 'contained'].includes(v)
}
}, 'VHotkeyVariant');
export const makeVHotkeyProps = propsFactory({
// String representing keyboard shortcuts (e.g., "ctrl+k", "meta+shift+p")
keys: String,
// How to display keys: 'symbol' uses special characters (⌘, ⌃), 'icon' uses SVG icons, 'text' uses words
displayMode: {
type: String,
default: 'icon'
},
// Custom key mapping configuration. Users can import and modify the exported hotkeyMap as needed
keyMap: {
type: Object,
default: () => hotkeyMap
},
platform: {
type: String,
default: 'auto'
},
inline: Boolean,
disabled: Boolean,
prefix: String,
suffix: String,
...makeComponentProps(),
...makeThemeProps(),
...makeBorderProps(),
...makeRoundedProps(),
...makeElevationProps(),
...makeVHotkeyVariantProps(),
color: String
}, 'VHotkey');
class Delineator {
constructor(delineator) {
if (['and', 'then'].includes(delineator)) this.val = delineator;else {
throw new Error('Not a valid delineator');
}
}
isEqual(d) {
return this.val === d.val;
}
}
function isDelineator(value) {
return value instanceof Delineator;
}
function isString(value) {
return typeof value === 'string';
}
function getKeyText(keyMap, key, isMac) {
const lowerKey = key.toLowerCase();
if (lowerKey in keyMap) {
const result = processKey(keyMap[lowerKey], 'text', isMac);
return typeof result[1] === 'string' ? result[1] : String(result[1]);
}
return key.toUpperCase();
}
function applyDisplayModeToKey(keyMap, mode, key, isMac) {
const lowerKey = key.toLowerCase();
if (lowerKey in keyMap) {
const result = processKey(keyMap[lowerKey], mode, isMac);
if (result[0] === 'text' && typeof result[1] === 'string' && result[1].startsWith('$') && !result[1].startsWith('$vuetify.')) {
return ['text', result[1].replace('$', '').toUpperCase(), key];
}
return [...result, key];
}
return ['text', key.toUpperCase(), key];
}
export const VHotkey = genericComponent()({
name: 'VHotkey',
props: makeVHotkeyProps(),
setup(props) {
const {
t
} = useLocale();
const {
themeClasses
} = provideTheme(props);
const {
rtlClasses
} = useRtl();
const {
borderClasses
} = useBorder(props);
const {
roundedClasses
} = useRounded(props);
const {
elevationClasses
} = useElevation(props);
const isContainedVariant = computed(() => props.variant === 'contained');
const effectiveVariantProps = computed(() => ({
...props,
variant: isContainedVariant.value ? 'elevated' : props.variant
}));
const {
colorClasses,
colorStyles,
variantClasses
} = useVariant(effectiveVariantProps);
const isMac = computed(() => props.platform === 'auto' ? typeof navigator !== 'undefined' && /macintosh/i.test(navigator.userAgent) : props.platform === 'mac');
const effectiveDisplayMode = computed(() => props.displayMode);
const AND_DELINEATOR = new Delineator('and'); // For + separators
const THEN_DELINEATOR = new Delineator('then'); // For - separators
const effectiveKeyMap = computed(() => props.keyMap);
const keyCombinations = computed(() => {
if (!props.keys) return [];
// Split by spaces to handle multiple key combinations
// Example: "ctrl+k meta+p" -> ["ctrl+k", "meta+p"]
return props.keys.split(' ').map(combination => {
// Use the shared sequence splitting logic
const sequenceGroups = splitKeySequence(combination);
// Process each sequence group
return sequenceGroups.flatMap((group, groupIndex) => {
// Use the shared key combination splitting logic
const keyParts = splitKeyCombination(group);
const parts = keyParts.reduce((acc, part, index) => {
if (index !== 0) {
// Add AND delineator between keys
return [...acc, AND_DELINEATOR, part];
}
return [...acc, part];
}, []);
// Add THEN delineator between sequence groups
const result = parts.map(key => {
if (isString(key)) {
return applyDisplayModeToKey(effectiveKeyMap.value, effectiveDisplayMode.value, key, isMac.value);
}
return key;
});
// Add sequence separator if not the last group
if (groupIndex < sequenceGroups.length - 1) {
result.push(THEN_DELINEATOR);
}
return result;
});
});
});
const accessibleLabel = computed(() => {
if (!props.keys) return '';
// Convert the parsed key combinations into readable text
const readableShortcuts = keyCombinations.value.map(combination => {
const readableParts = [];
for (const key of combination) {
if (isDelineator(key)) {
if (AND_DELINEATOR.isEqual(key)) {
readableParts.push(t('$vuetify.hotkey.plus'));
} else if (THEN_DELINEATOR.isEqual(key)) {
readableParts.push(t('$vuetify.hotkey.then'));
}
} else {
// Always use text representation for screen readers
const textKey = key[0] === 'icon' || key[0] === 'symbol' ? applyDisplayModeToKey(mergeDeep(hotkeyMap, props.keyMap), 'text', String(key[1]), isMac.value)[1] : key[1];
readableParts.push(translateKey(textKey));
}
}
return readableParts.join(' ');
});
const shortcutText = readableShortcuts.join(', ');
return t('$vuetify.hotkey.shortcut', shortcutText);
});
function translateKey(key) {
return key.startsWith('$vuetify.') ? t(key) : key;
}
function getKeyTooltip(key) {
if (effectiveDisplayMode.value === 'text') return undefined;
const textKey = getKeyText(effectiveKeyMap.value, String(key[2]), isMac.value);
return translateKey(textKey);
}
function renderKey(key, keyIndex, isContained) {
const KeyComponent = isContained ? 'kbd' : VKbd;
const keyClasses = ['v-hotkey__key', `v-hotkey__key-${key[0]}`, ...(isContained ? ['v-hotkey__key--nested'] : [borderClasses.value, roundedClasses.value, elevationClasses.value, colorClasses.value])];
return _createVNode(KeyComponent, {
"key": keyIndex,
"class": _normalizeClass(keyClasses),
"style": _normalizeStyle(isContained ? undefined : colorStyles.value),
"aria-hidden": "true",
"title": getKeyTooltip(key)
}, {
default: () => [key[0] === 'icon' ? _createVNode(VIcon, {
"icon": key[1],
"aria-hidden": "true"
}, null) : translateKey(key[1])]
});
}
function renderDivider(key, keyIndex) {
return _createElementVNode("span", {
"key": keyIndex,
"class": "v-hotkey__divider",
"aria-hidden": "true"
}, [AND_DELINEATOR.isEqual(key) ? '+' : t('$vuetify.hotkey.then')]);
}
useRender(() => _createElementVNode("div", {
"class": _normalizeClass(['v-hotkey', {
'v-hotkey--disabled': props.disabled,
'v-hotkey--inline': props.inline,
'v-hotkey--contained': isContainedVariant.value
}, themeClasses.value, rtlClasses.value, variantClasses.value, props.class]),
"style": _normalizeStyle(props.style),
"role": "img",
"aria-label": accessibleLabel.value
}, [isContainedVariant.value ? _createVNode(VKbd, {
"key": "contained",
"class": _normalizeClass(['v-hotkey__contained-wrapper', borderClasses.value, roundedClasses.value, elevationClasses.value, colorClasses.value]),
"style": _normalizeStyle(colorStyles.value),
"aria-hidden": "true"
}, {
default: () => [props.prefix && _createElementVNode("span", {
"key": "contained-prefix",
"class": "v-hotkey__prefix"
}, [props.prefix]), keyCombinations.value.map((combination, comboIndex) => _createElementVNode("span", {
"class": "v-hotkey__combination",
"key": comboIndex
}, [combination.map((key, keyIndex) => isDelineator(key) ? renderDivider(key, keyIndex) : renderKey(key, keyIndex, true)), comboIndex < keyCombinations.value.length - 1 && _createElementVNode("span", {
"aria-hidden": "true"
}, [_createTextVNode("\xA0")])])), props.suffix && _createElementVNode("span", {
"key": "contained-suffix",
"class": "v-hotkey__suffix"
}, [props.suffix])]
}) : _createElementVNode(_Fragment, null, [props.prefix && _createElementVNode("span", {
"key": "prefix",
"class": "v-hotkey__prefix"
}, [props.prefix]), keyCombinations.value.map((combination, comboIndex) => _createElementVNode("span", {
"class": "v-hotkey__combination",
"key": comboIndex
}, [combination.map((key, keyIndex) => isDelineator(key) ? renderDivider(key, keyIndex) : renderKey(key, keyIndex, false)), comboIndex < keyCombinations.value.length - 1 && _createElementVNode("span", {
"aria-hidden": "true"
}, [_createTextVNode("\xA0")])])), props.suffix && _createElementVNode("span", {
"key": "suffix",
"class": "v-hotkey__suffix"
}, [props.suffix])])]));
}
});
//# sourceMappingURL=VHotkey.js.map