Skip to content

Custom range filter

Basic usage

tsx
import { Flex } from '@semcore/ui/base-components';
import { FilterTrigger } from '@semcore/ui/base-trigger';
import Button from '@semcore/ui/button';
import Dropdown from '@semcore/ui/dropdown';
import InputNumber from '@semcore/ui/input-number';
import { Text } from '@semcore/ui/typography';
import React, { useState, useRef } from 'react';

interface ValueState {
  from: string;
  to: string;
}

interface InputRangeProps {
  value: ValueState;
  changeValue: (updatedValue: ValueState) => void;
  [key: string]: unknown;
}

const InputRange: React.FC<InputRangeProps> = ({ value: valueState, changeValue, ...other }) => {
  const minRange = 1;
  const maxRange = 8;

  const fromRef = useRef<HTMLInputElement | null>(null);
  const toRef = useRef<HTMLInputElement | null>(null);

  const handleChange = (key: keyof ValueState) => (value: string | null) => {
    valueState[key] = value ?? '';
    changeValue({ ...valueState });
  };

  const handleBlur = () => {
    setTimeout(() => {
      if (document.activeElement !== fromRef.current && document.activeElement !== toRef.current) {
        const { from, to } = valueState;
        if (from > to && to !== '') {
          changeValue({
            from: Math.max(Number(to), minRange).toString(),
            to: Math.min(Number(from), maxRange).toString(),
          });
        }
        if ((to === '' || from === '') && (to !== '' || from !== '')) {
          changeValue({
            from: from !== '' ? from : minRange.toString(),
            to: to !== '' ? to : maxRange.toString(),
          });
        }
      }
    }, 0);
  };

  const { from, to } = valueState;

  return (
    <Flex {...other}>
      <InputNumber neighborLocation='right'>
        <InputNumber.Value
          min={minRange}
          max={maxRange}
          aria-label='From'
          placeholder='From'
          value={from}
          onChange={handleChange('from')}
          onBlur={handleBlur}
          ref={fromRef}
          autoFocus
        />
        <InputNumber.Controls />
      </InputNumber>
      <InputNumber neighborLocation='left'>
        <InputNumber.Value
          min={minRange}
          max={maxRange}
          aria-label='To'
          placeholder='To'
          value={to}
          onChange={handleChange('to')}
          onBlur={handleBlur}
          ref={toRef}
        />
        <InputNumber.Controls />
      </InputNumber>
    </Flex>
  );
};

const setTriggerText = ({ from, to }: { from: string; to: string }): string | null => {
  if (from !== '' && to !== '') {
    return from === to ? `${from}` : `${from}–${to}`;
  }
  return null;
};

const Demo = () => {
  const [value, setValue] = useState<ValueState>({ from: '', to: '' });
  const [filters, setFilters] = useState(false);
  const [visible, setVisible] = useState(false);
  const [displayValue, setDisplayValue] = useState('');
  const clearAll = () => {
    setFilters(false);
    setValue({ from: '', to: '' });
    setVisible(false);
  };
  const applyFilters = () => {
    const { from, to } = value;
    setVisible(false);
    setFilters(!!(from || to));
    setDisplayValue(setTriggerText(value) ?? '');
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (e.key === 'Enter') {
      applyFilters();
    }
  };

  return (
    <Dropdown visible={visible} onVisibleChange={setVisible}>
      <Dropdown.Trigger
        placeholder='Competitive Density'
        aria-label='Competitive Density'
        empty={!filters}
        onClear={clearAll}
        tag={FilterTrigger}
      >
        <span aria-hidden>Com.:</span>
        {' '}
        {displayValue}
      </Dropdown.Trigger>
      <Dropdown.Popper w={240} p={2} pb={3} aria-label='Competitive Density' tabIndex={-1}>
        <Text id='title-CD' size={200} bold>
          Custom range
        </Text>
        <InputRange
          role='group'
          aria-labelledby='title-CD'
          value={value}
          changeValue={setValue}
          my={2}
          onKeyDown={handleKeyDown}
        />
        <Button use='primary' theme='info' w='100%' onClick={applyFilters}>
          Apply
        </Button>
      </Dropdown.Popper>
    </Dropdown>
  );
};

export default Demo;

Presets

tsx
import { Flex } from '@semcore/ui/base-components';
import { FilterTrigger } from '@semcore/ui/base-trigger';
import Button from '@semcore/ui/button';
import Divider from '@semcore/ui/divider';
import InputNumber from '@semcore/ui/input-number';
import Select from '@semcore/ui/select';
import { Text } from '@semcore/ui/typography';
import React, { useState, useRef } from 'react';

interface ValueState {
  from: string;
  to: string;
}

interface InputRangeProps {
  value: ValueState;
  changeValue: (updatedValue: ValueState) => void;
  [key: string]: unknown;
}

const minRange = 1;

const InputRange: React.FC<InputRangeProps> = ({ value: valueState, changeValue, ...other }) => {
  const fromRef = useRef<HTMLInputElement | null>(null);
  const toRef = useRef<HTMLInputElement | null>(null);

  const handleChange = (key: keyof ValueState) => (value: string | number | undefined) => {
    changeValue({
      ...valueState,
      [key]: value !== undefined ? String(value) : '',
    });
  };

  const handleBlur = () => {
    setTimeout(() => {
      if (document.activeElement !== fromRef.current && document.activeElement !== toRef.current) {
        const from = Number(valueState.from);
        const to = Number(valueState.to);
        if (from > to && valueState.to !== '') {
          changeValue({
            from: to > minRange ? to.toString() : minRange.toString(),
            to: from.toString(),
          });
        }
        if (valueState.from === '' && valueState.to !== '') {
          changeValue({
            from: minRange.toString(),
            to: valueState.to,
          });
        }
      }
    }, 0);
  };

  const { from, to } = valueState;

  return (
    <Flex {...other}>
      <InputNumber neighborLocation='right'>
        <InputNumber.Value
          min={minRange}
          aria-label='From'
          placeholder='From'
          value={from}
          onChange={handleChange('from')}
          onBlur={handleBlur}
          ref={fromRef}
        />
        <InputNumber.Controls />
      </InputNumber>
      <InputNumber neighborLocation='left'>
        <InputNumber.Value
          min={minRange}
          aria-label='To'
          placeholder='To'
          value={to}
          onChange={handleChange('to')}
          onBlur={handleBlur}
          ref={toRef}
        />
        <InputNumber.Controls />
      </InputNumber>
    </Flex>
  );
};

const numberFormat = new Intl.NumberFormat('en-US');

const setTriggerText = ({ from, to }: ValueState): string | undefined => {
  if (from !== '') {
    if (to === '') return `${numberFormat.format(Number(from))}+`;
    if (from === to) return numberFormat.format(Number(from));
    return `${numberFormat.format(Number(from))}–${numberFormat.format(Number(to))}`;
  } else if (to !== '') {
    return `${numberFormat.format(minRange)}–${numberFormat.format(Number(to))}`;
  }
  return undefined;
};

const Demo = () => {
  const [visible, setVisible] = useState(false);
  const [customRange, setCustomRange] = useState<ValueState>({ from: '', to: '' });
  const [selectValue, setSelectValue] = useState<string | undefined>(undefined);
  const [displayValue, setDisplayValue] = useState<string | undefined>(undefined);
  const triggerRef = useRef<HTMLButtonElement | null>(null);

  const clearAll = () => {
    setCustomRange({ from: '', to: '' });
    setSelectValue(undefined);
    setDisplayValue(undefined);
  };

  const applyFilters = () => {
    setVisible(false);
    setDisplayValue(setTriggerText(customRange));
    setSelectValue(undefined);
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    e.stopPropagation();

    if (
      e.key === 'Tab' &&
      e.shiftKey &&
      e.target instanceof HTMLElement &&
      e.target.getAttribute('aria-label') === 'From'
    ) {
      e.preventDefault();
      triggerRef.current?.focus();
    }

    if (e.key === 'Enter') {
      e.preventDefault();
      applyFilters();
    }

    if (e.key === 'Escape') {
      e.preventDefault();
      setVisible(false);
    }
  };

  const handleKeyDownApply = (e: React.KeyboardEvent) => {
    if (e.key === 'Tab' && !e.shiftKey) {
      e.preventDefault();
      e.stopPropagation();
      triggerRef.current?.focus();
    }
  };

  const handleSelect = (value: string) => {
    setDisplayValue(value);
    setSelectValue(value);
    setCustomRange({ from: '', to: '' });
  };

  return (
    <Select
      visible={visible}
      onVisibleChange={setVisible}
      onChange={handleSelect}
      value={selectValue}
    >
      <Select.Trigger
        placeholder='Volume'
        aria-label='Volume'
        active={visible}
        empty={!displayValue}
        onClear={clearAll}
        tag={FilterTrigger}
        triggerRef={triggerRef}
      >
        <span aria-hidden>Volume: </span>
        {displayValue}
      </Select.Trigger>
      <Select.Popper w={224} aria-label='Volume'>
        <Select.List aria-label='Presets'>
          {['100,001+', '10,001–100,000', '1,001–10,000', '101–1,000', '11–100', '1–10'].map(
            (item) => (
              <Select.Option key={item} value={item}>
                {item}
              </Select.Option>
            ),
          )}
        </Select.List>
        <Divider my={1} />
        <Flex px={2} pt={1} pb={3} gap={2} direction='column'>
          <Text id='custom-range-title' size={200} bold>
            Custom range
          </Text>
          <InputRange
            role='group'
            aria-labelledby='custom-range-title'
            value={customRange}
            changeValue={setCustomRange}
            onKeyDown={handleKeyDown}
          />
          <Button
            use='primary'
            theme='info'
            w='100%'
            onClick={applyFilters}
            onKeyDown={handleKeyDownApply}
          >
            Apply
          </Button>
        </Flex>
      </Select.Popper>
    </Select>
  );
};

export default Demo;

Released under the MIT License.

Released under the MIT License.