@metropolle/design-system 1.0.0-beta.2025.12.14.1932.93a1534 → 1.0.0-beta.2025.12.29.1122.b256706

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import require$$0, { forwardRef, useState, useEffect, useMemo, useRef } from 'react';
1
+ import require$$0, { forwardRef, useState, useRef, useEffect, useMemo, useCallback } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
 
4
4
  var jsxRuntime = {exports: {}};
@@ -1520,22 +1520,241 @@ const LoadingSpinner = () => (jsxRuntimeExports.jsxs("svg", { className: "mds-sp
1520
1520
  /**
1521
1521
  * Select Component (Design System)
1522
1522
  *
1523
- * Provides a themed select element with multiple variants:
1524
- * - `base`: Standard form select with mds-input styling
1525
- * - `themed`: Generic themed select with dashboard control styling (recommended)
1526
- * - `dashboard`: Legacy alias for themed variant (backward compatibility)
1523
+ * Custom dropdown select that renders consistently across all browsers.
1524
+ * Unlike native <select>, this component renders the dropdown via JavaScript,
1525
+ * ensuring proper theming support on Edge/Chrome Windows.
1526
+ *
1527
+ * @example
1528
+ * ```tsx
1529
+ * <Select
1530
+ * options={[
1531
+ * { label: 'Option 1', value: '1' },
1532
+ * { label: 'Option 2', value: '2' },
1533
+ * ]}
1534
+ * value={selectedValue}
1535
+ * onChange={setSelectedValue}
1536
+ * placeholder="Select an option..."
1537
+ * />
1538
+ * ```
1527
1539
  */
1528
- const Select = forwardRef(({ options, children, className, containerClassName, variant = 'themed', ...rest }, ref) => {
1529
- const isThemed = variant === 'themed' || variant === 'dashboard';
1530
- const selectEl = (jsxRuntimeExports.jsx("select", { ref: ref, className: cn(isThemed
1531
- ? 'mds-select-themed'
1532
- : 'mds-input mds-select', className), ...rest, children: options
1533
- ? options.map(opt => (jsxRuntimeExports.jsx("option", { value: opt.value, children: opt.label }, opt.value)))
1534
- : children }));
1535
- if (isThemed) {
1536
- return (jsxRuntimeExports.jsx("div", { className: cn('mds-dropdown', containerClassName), children: selectEl }));
1537
- }
1538
- return selectEl;
1540
+ const Select = forwardRef(({ options, value, onChange, placeholder = 'Select...', variant = 'themed', size = 'md', disabled = false, loading = false, error = false, className, dropdownClassName, id, name, 'aria-label': ariaLabel, fullWidth = false, searchable = false, searchPlaceholder = 'Search...', maxHeight = 300, zIndex = 1050, }, ref) => {
1541
+ const [isOpen, setIsOpen] = useState(false);
1542
+ const [searchTerm, setSearchTerm] = useState('');
1543
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
1544
+ const [mounted, setMounted] = useState(false);
1545
+ const triggerRef = useRef(null);
1546
+ const dropdownRef = useRef(null);
1547
+ const searchInputRef = useRef(null);
1548
+ const listRef = useRef(null);
1549
+ // Combine refs
1550
+ const combinedRef = (el) => {
1551
+ triggerRef.current = el;
1552
+ if (typeof ref === 'function') {
1553
+ ref(el);
1554
+ }
1555
+ else if (ref) {
1556
+ ref.current = el;
1557
+ }
1558
+ };
1559
+ // Client-side only
1560
+ useEffect(() => {
1561
+ setMounted(true);
1562
+ }, []);
1563
+ // Filter options based on search
1564
+ const filteredOptions = useMemo(() => {
1565
+ if (!searchTerm)
1566
+ return options;
1567
+ const term = searchTerm.toLowerCase();
1568
+ return options.filter(opt => {
1569
+ const label = typeof opt.label === 'string' ? opt.label : String(opt.value);
1570
+ return label.toLowerCase().includes(term);
1571
+ });
1572
+ }, [options, searchTerm]);
1573
+ // Get selected option label
1574
+ const selectedOption = useMemo(() => {
1575
+ return options.find(opt => opt.value === value);
1576
+ }, [options, value]);
1577
+ // Handle dropdown positioning
1578
+ const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
1579
+ const updateDropdownPosition = useCallback(() => {
1580
+ if (!triggerRef.current)
1581
+ return;
1582
+ const rect = triggerRef.current.getBoundingClientRect();
1583
+ const viewportHeight = window.innerHeight;
1584
+ const spaceBelow = viewportHeight - rect.bottom;
1585
+ const spaceAbove = rect.top;
1586
+ // Determine if dropdown should open above or below
1587
+ const dropdownHeight = Math.min(maxHeight, filteredOptions.length * 40 + (searchable ? 48 : 0));
1588
+ const openAbove = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
1589
+ setDropdownPosition({
1590
+ top: openAbove ? rect.top - dropdownHeight : rect.bottom + 4,
1591
+ left: rect.left,
1592
+ width: rect.width,
1593
+ });
1594
+ }, [maxHeight, filteredOptions.length, searchable]);
1595
+ // Open dropdown
1596
+ const openDropdown = useCallback(() => {
1597
+ if (disabled || loading)
1598
+ return;
1599
+ updateDropdownPosition();
1600
+ setIsOpen(true);
1601
+ setSearchTerm('');
1602
+ setHighlightedIndex(value ? filteredOptions.findIndex(opt => opt.value === value) : 0);
1603
+ }, [disabled, loading, updateDropdownPosition, value, filteredOptions]);
1604
+ // Close dropdown
1605
+ const closeDropdown = useCallback(() => {
1606
+ setIsOpen(false);
1607
+ setSearchTerm('');
1608
+ setHighlightedIndex(-1);
1609
+ triggerRef.current?.focus();
1610
+ }, []);
1611
+ // Handle option select
1612
+ const handleSelect = useCallback((optionValue) => {
1613
+ // Safety check: ensure we're passing a string, not an object
1614
+ const safeValue = typeof optionValue === 'string' ? optionValue : String(optionValue);
1615
+ onChange?.(safeValue);
1616
+ closeDropdown();
1617
+ }, [onChange, closeDropdown]);
1618
+ // Keyboard navigation
1619
+ const handleKeyDown = useCallback((e) => {
1620
+ if (disabled || loading)
1621
+ return;
1622
+ switch (e.key) {
1623
+ case 'Enter':
1624
+ case ' ':
1625
+ e.preventDefault();
1626
+ if (isOpen && highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
1627
+ const opt = filteredOptions[highlightedIndex];
1628
+ if (!opt.disabled) {
1629
+ handleSelect(opt.value);
1630
+ }
1631
+ }
1632
+ else if (!isOpen) {
1633
+ openDropdown();
1634
+ }
1635
+ break;
1636
+ case 'ArrowDown':
1637
+ e.preventDefault();
1638
+ if (!isOpen) {
1639
+ openDropdown();
1640
+ }
1641
+ else {
1642
+ setHighlightedIndex(prev => {
1643
+ const next = prev + 1;
1644
+ return next >= filteredOptions.length ? 0 : next;
1645
+ });
1646
+ }
1647
+ break;
1648
+ case 'ArrowUp':
1649
+ e.preventDefault();
1650
+ if (isOpen) {
1651
+ setHighlightedIndex(prev => {
1652
+ const next = prev - 1;
1653
+ return next < 0 ? filteredOptions.length - 1 : next;
1654
+ });
1655
+ }
1656
+ break;
1657
+ case 'Escape':
1658
+ e.preventDefault();
1659
+ closeDropdown();
1660
+ break;
1661
+ case 'Tab':
1662
+ if (isOpen) {
1663
+ closeDropdown();
1664
+ }
1665
+ break;
1666
+ case 'Home':
1667
+ if (isOpen) {
1668
+ e.preventDefault();
1669
+ setHighlightedIndex(0);
1670
+ }
1671
+ break;
1672
+ case 'End':
1673
+ if (isOpen) {
1674
+ e.preventDefault();
1675
+ setHighlightedIndex(filteredOptions.length - 1);
1676
+ }
1677
+ break;
1678
+ }
1679
+ }, [disabled, loading, isOpen, highlightedIndex, filteredOptions, handleSelect, openDropdown, closeDropdown]);
1680
+ // Click outside to close
1681
+ useEffect(() => {
1682
+ if (!isOpen)
1683
+ return;
1684
+ const handleClickOutside = (e) => {
1685
+ if (triggerRef.current?.contains(e.target) ||
1686
+ dropdownRef.current?.contains(e.target)) {
1687
+ return;
1688
+ }
1689
+ closeDropdown();
1690
+ };
1691
+ document.addEventListener('mousedown', handleClickOutside);
1692
+ return () => document.removeEventListener('mousedown', handleClickOutside);
1693
+ }, [isOpen, closeDropdown]);
1694
+ // Update position on scroll/resize
1695
+ useEffect(() => {
1696
+ if (!isOpen)
1697
+ return;
1698
+ const handleUpdate = () => updateDropdownPosition();
1699
+ window.addEventListener('scroll', handleUpdate, true);
1700
+ window.addEventListener('resize', handleUpdate);
1701
+ return () => {
1702
+ window.removeEventListener('scroll', handleUpdate, true);
1703
+ window.removeEventListener('resize', handleUpdate);
1704
+ };
1705
+ }, [isOpen, updateDropdownPosition]);
1706
+ // Focus search input when dropdown opens
1707
+ useEffect(() => {
1708
+ if (isOpen && searchable && searchInputRef.current) {
1709
+ searchInputRef.current.focus();
1710
+ }
1711
+ }, [isOpen, searchable]);
1712
+ // Scroll highlighted option into view
1713
+ useEffect(() => {
1714
+ if (!isOpen || highlightedIndex < 0 || !listRef.current)
1715
+ return;
1716
+ const highlighted = listRef.current.children[highlightedIndex];
1717
+ if (highlighted) {
1718
+ highlighted.scrollIntoView({ block: 'nearest' });
1719
+ }
1720
+ }, [isOpen, highlightedIndex]);
1721
+ // Size classes
1722
+ const sizeClasses = {
1723
+ sm: 'mds-select--sm',
1724
+ md: 'mds-select--md',
1725
+ lg: 'mds-select--lg',
1726
+ };
1727
+ // Variant classes
1728
+ const variantClasses = {
1729
+ base: 'mds-select--base',
1730
+ themed: 'mds-select--themed',
1731
+ dashboard: 'mds-select--themed',
1732
+ };
1733
+ const triggerClasses = cn('mds-select-trigger', sizeClasses[size], variantClasses[variant], isOpen && 'mds-select-trigger--open', disabled && 'mds-select-trigger--disabled', loading && 'mds-select-trigger--loading', error && 'mds-select-trigger--error', fullWidth && 'mds-select-trigger--full-width', className);
1734
+ const dropdownClasses = cn('mds-select-dropdown', variantClasses[variant], dropdownClassName);
1735
+ // Hidden input for form submission
1736
+ const hiddenInput = name ? (jsxRuntimeExports.jsx("input", { type: "hidden", name: name, value: value || '' })) : null;
1737
+ // Dropdown portal content
1738
+ const dropdownContent = isOpen && mounted ? createPortal(jsxRuntimeExports.jsxs("div", { ref: dropdownRef, className: dropdownClasses, style: {
1739
+ position: 'fixed',
1740
+ top: dropdownPosition.top,
1741
+ left: dropdownPosition.left,
1742
+ width: dropdownPosition.width,
1743
+ maxHeight,
1744
+ zIndex,
1745
+ }, role: "listbox", "aria-label": ariaLabel || placeholder, children: [searchable && (jsxRuntimeExports.jsxs("div", { className: "mds-select-search", children: [jsxRuntimeExports.jsx("input", { ref: searchInputRef, type: "text", className: "mds-select-search__input", placeholder: searchPlaceholder, value: searchTerm, onChange: (e) => {
1746
+ setSearchTerm(e.target.value);
1747
+ setHighlightedIndex(0);
1748
+ }, onKeyDown: handleKeyDown, "aria-label": "Search options" }), jsxRuntimeExports.jsx("svg", { className: "mds-select-search__icon", viewBox: "0 0 20 20", fill: "currentColor", children: jsxRuntimeExports.jsx("path", { fillRule: "evenodd", d: "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", clipRule: "evenodd" }) })] })), jsxRuntimeExports.jsx("ul", { ref: listRef, className: "mds-select-options", children: filteredOptions.length === 0 ? (jsxRuntimeExports.jsx("li", { className: "mds-select-option mds-select-option--empty", children: "No options found" })) : (filteredOptions.map((option, index) => (jsxRuntimeExports.jsxs("li", { className: cn('mds-select-option', option.value === value && 'mds-select-option--selected', index === highlightedIndex && 'mds-select-option--highlighted', option.disabled && 'mds-select-option--disabled'), role: "option", "aria-selected": option.value === value, "aria-disabled": option.disabled, onClick: () => {
1749
+ if (!option.disabled) {
1750
+ handleSelect(option.value);
1751
+ }
1752
+ }, onMouseEnter: () => {
1753
+ if (!option.disabled) {
1754
+ setHighlightedIndex(index);
1755
+ }
1756
+ }, children: [jsxRuntimeExports.jsx("span", { className: "mds-select-option__label", children: option.label }), option.value === value && (jsxRuntimeExports.jsx("svg", { className: "mds-select-option__check", viewBox: "0 0 20 20", fill: "currentColor", children: jsxRuntimeExports.jsx("path", { fillRule: "evenodd", d: "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", clipRule: "evenodd" }) }))] }, option.value)))) })] }), document.body) : null;
1757
+ return (jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [hiddenInput, jsxRuntimeExports.jsxs("button", { ref: combinedRef, type: "button", id: id, className: triggerClasses, onClick: () => isOpen ? closeDropdown() : openDropdown(), onKeyDown: handleKeyDown, disabled: disabled || loading, "aria-haspopup": "listbox", "aria-expanded": isOpen, "aria-label": ariaLabel, "aria-invalid": error, children: [jsxRuntimeExports.jsx("span", { className: cn('mds-select-trigger__value', !selectedOption && 'mds-select-trigger__placeholder'), children: loading ? 'Loading...' : (selectedOption?.label || placeholder) }), jsxRuntimeExports.jsx("span", { className: "mds-select-trigger__icon", children: loading ? (jsxRuntimeExports.jsx("svg", { className: "mds-select-spinner", viewBox: "0 0 24 24", fill: "none", children: jsxRuntimeExports.jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeDasharray: "32", strokeDashoffset: "32", children: jsxRuntimeExports.jsx("animate", { attributeName: "stroke-dashoffset", values: "32;0", dur: "1s", repeatCount: "indefinite" }) }) })) : (jsxRuntimeExports.jsx("svg", { className: "mds-select-chevron", viewBox: "0 0 20 20", fill: "currentColor", children: jsxRuntimeExports.jsx("path", { fillRule: "evenodd", d: "M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z", clipRule: "evenodd" }) })) })] }), dropdownContent] }));
1539
1758
  });
1540
1759
  Select.displayName = 'Select';
1541
1760