@peassoft/mnr-web-ui-kit 0.1.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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ ## The MIT License (MIT) ##
2
+
3
+ Copyright (c) 2024 Alexey Gorokhov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # Memorize'n'Revise Web UI Kit
@@ -0,0 +1,24 @@
1
+ import { type JSX } from 'react';
2
+ import './styles.css';
3
+ type Props = {
4
+ /** Button label */
5
+ label: string;
6
+ /** Kind of the button */
7
+ variant?: 'primary' | 'secondary';
8
+ /** Size of the button */
9
+ size?: 'normal' | 'small';
10
+ /** Whether the button is stretched to the size of its container */
11
+ stretched?: boolean;
12
+ /** Whether action initiated by click on the button is in progress */
13
+ isInProgress?: boolean;
14
+ /** Ref */
15
+ ref?: React.RefObject<HTMLButtonElement | null>;
16
+ /** onClick event handler; is ignored if props.isInProgress === true */
17
+ onClick: () => unknown;
18
+ /** Optional handler of Tab key press */
19
+ onTab?: () => unknown;
20
+ /** Optional handler of Shift + Tab key press */
21
+ onShiftTab?: () => unknown;
22
+ };
23
+ export declare function Button(props: Props): JSX.Element;
24
+ export {};
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback } from 'react';
3
+ import './styles.css';
4
+ import processClick from './process-click.js';
5
+ import processKeyDown from './process-key-down.js';
6
+ export function Button(props) {
7
+ const {
8
+ label,
9
+ variant = 'primary',
10
+ size = 'normal',
11
+ stretched = false,
12
+ isInProgress = false,
13
+ ref,
14
+ onClick,
15
+ onTab,
16
+ onShiftTab
17
+ } = props;
18
+ const handleClick = useCallback(() => processClick(isInProgress, onClick), [isInProgress, onClick]);
19
+ const handleKeyDown = useCallback(e => processKeyDown(e, onTab, onShiftTab), [onTab, onShiftTab]);
20
+ const btnCn = 'uikit_Button_btn' + (variant === 'secondary' ? ' uikit_Button_btn--secondary' : '') + (stretched ? ' uikit_Button_btn--stretched uikit_Button_btn--withTopMargin' : ' uikit_Button_btn--withLeftMargin') + (size === 'small' ? ' uikit_Button_btn--small' : '');
21
+ const labelCn = isInProgress ? 'uikit_Button_label--hidden' : '';
22
+ return _jsxs("button", {
23
+ ref: ref,
24
+ type: 'button',
25
+ className: btnCn,
26
+ onClick: handleClick,
27
+ onKeyDown: handleKeyDown,
28
+ children: [_jsx("span", {
29
+ className: labelCn,
30
+ children: label
31
+ }), !!isInProgress && _jsx("div", {
32
+ className: 'uikit_Button_spinner',
33
+ "data-test-id": 'spinner'
34
+ })]
35
+ });
36
+ }
@@ -0,0 +1 @@
1
+ export default function processClick(isInProgress: boolean, onClick: () => void): void;
@@ -0,0 +1,3 @@
1
+ export default function processClick(isInProgress, onClick) {
2
+ if (!isInProgress) onClick();
3
+ }
@@ -0,0 +1,2 @@
1
+ export declare const TAB_KEYCODE = 9;
2
+ export default function processKeyDown(e: Pick<React.KeyboardEvent, 'which' | 'shiftKey' | 'preventDefault'>, onTab: (() => unknown) | undefined, onShiftTab: (() => unknown) | undefined): void;
@@ -0,0 +1,12 @@
1
+ export const TAB_KEYCODE = 9;
2
+ export default function processKeyDown(e, onTab, onShiftTab) {
3
+ if (e.which === TAB_KEYCODE && onTab && !e.shiftKey) {
4
+ e.preventDefault();
5
+ onTab();
6
+ return;
7
+ }
8
+ if (e.which === TAB_KEYCODE && onShiftTab && e.shiftKey) {
9
+ e.preventDefault();
10
+ onShiftTab();
11
+ }
12
+ }
@@ -0,0 +1,76 @@
1
+ .uikit_Button_btn {
2
+ display: block;
3
+ appearance: none;
4
+ border: 0;
5
+ color: #f0e9d2;
6
+ background-color: #678983;
7
+ border-radius: 5px;
8
+ font-family: inherit;
9
+ font-size: 1em;
10
+ margin: 0;
11
+ padding: 1em 2em;
12
+ cursor: pointer;
13
+ position: relative;
14
+ outline-offset: 1px;
15
+ }
16
+
17
+ .uikit_Button_btn--secondary {
18
+ color: #678983;
19
+ background-color: transparent;
20
+ border: 1px solid currentColor;
21
+ }
22
+
23
+ .uikit_Button_btn--stretched {
24
+ width: 100%;
25
+ }
26
+
27
+ .uikit_Button_btn--small {
28
+ padding: .8em 1.4em;
29
+ }
30
+
31
+ .uikit_Button_btn--withLeftMargin:not(:first-child) {
32
+ margin-left: 1em;
33
+ }
34
+
35
+ .uikit_Button_btn--withTopMargin:not(:first-child) {
36
+ margin-top: 1em;
37
+ }
38
+
39
+ .uikit_Button_label--hidden {
40
+ opacity: 0;
41
+ }
42
+
43
+ .uikit_Button_spinner {
44
+ width: 6px;
45
+ height: 6px;
46
+ border-radius: 50%;
47
+ background-color: currentColor;
48
+ animation: uikit_Button_spinner_animation 1s linear infinite alternate;
49
+ position: absolute;
50
+ left: 50%;
51
+ top: 50%;
52
+ transform: translate(calc(-50% - 12px), -50%);
53
+ }
54
+
55
+ @media (prefers-reduced-motion: reduce) {
56
+ .uikit_Button_spinner {
57
+ box-shadow: 12px 0px 0px 0px currentColor, 24px 0px 0px 0px currentColor;
58
+ }
59
+ }
60
+
61
+ @keyframes uikit_Button_spinner_animation{
62
+ 0% {
63
+ opacity: 1;
64
+ box-shadow: 12px 0px 0px 0px currentColor, 24px 0px 0px 0px currentColor;
65
+ }
66
+
67
+ 25% {
68
+ opacity: 0.4;
69
+ box-shadow: 12px 0px 0px 0px currentColor, 24px 0px 0px 0px currentColor;
70
+ }
71
+
72
+ 75% {
73
+ opacity: 0.4;
74
+ box-shadow: 12px 0px 0px 0px currentColor, 24px 0px 0px 0px currentColor;
75
+ }
76
+ }
@@ -0,0 +1,46 @@
1
+ import { type JSX } from 'react';
2
+ import './styles.css';
3
+ export type ButtonRadioGroupItem = {
4
+ /** Unique item ID */
5
+ id: string;
6
+ /** Contents of the button */
7
+ contents: React.ReactNode;
8
+ /**
9
+ * Optional description label.
10
+ * Should be provided if button contents does not contain descriptive text.
11
+ */
12
+ a11yLabel?: string;
13
+ };
14
+ type Props = {
15
+ /**
16
+ * Optional. Visible label for the entire group of radio buttons.
17
+ *
18
+ * If `label` is provided, `groupA11yLabel` is ignored.
19
+ * If `label` is NOT provided, you should provide `groupA11yLabel`.
20
+ */
21
+ label?: string;
22
+ /**
23
+ * Optional. Accessability label for the entire group of radio buttons.
24
+ *
25
+ * If `label` is provided, `groupA11yLabel` is ignored.
26
+ */
27
+ groupA11yLabel?: string;
28
+ /** Items of the group */
29
+ items: ButtonRadioGroupItem[];
30
+ /** ID of the currently checked item */
31
+ selectedItemId: string;
32
+ /** Handler for change event */
33
+ onChange: (itemId: string) => unknown;
34
+ /**
35
+ * Exposes react ref object that might be used for setting focus on the currently
36
+ * checked item from outside the component
37
+ */
38
+ focusHandle?: React.RefObject<HTMLButtonElement | null>;
39
+ };
40
+ /**
41
+ * Standalone radio group widget styled as a set of toggle buttons.
42
+ *
43
+ * Note: Not for use within a toolbar.
44
+ */
45
+ export declare function ButtonRadioGroup(props: Props): JSX.Element;
46
+ export {};
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useMemo, useId, createRef } from 'react';
3
+ import './styles.css';
4
+ /**
5
+ * Standalone radio group widget styled as a set of toggle buttons.
6
+ *
7
+ * Note: Not for use within a toolbar.
8
+ */
9
+ export function ButtonRadioGroup(props) {
10
+ const {
11
+ label,
12
+ groupA11yLabel,
13
+ items,
14
+ selectedItemId,
15
+ onChange,
16
+ focusHandle
17
+ } = props;
18
+ const labelDomId = useId();
19
+ const buttonRefs = useMemo(() => items.map(item => {
20
+ return item.id === selectedItemId ? focusHandle : createRef();
21
+ }), [items, selectedItemId, focusHandle]);
22
+ const handleKeyDown = useCallback(event => {
23
+ const currIdx = items.findIndex(item => item.id === selectedItemId);
24
+ let nextIdx = null;
25
+ switch (event.key) {
26
+ case 'ArrowRight':
27
+ case 'ArrowDown':
28
+ nextIdx = currIdx + 1;
29
+ if (nextIdx > items.length - 1) nextIdx = 0;
30
+ break;
31
+ case 'ArrowLeft':
32
+ case 'ArrowUp':
33
+ nextIdx = currIdx - 1;
34
+ if (nextIdx < 0) nextIdx = items.length - 1;
35
+ }
36
+ if (nextIdx != null) {
37
+ const nextItem = items[nextIdx];
38
+ if (nextItem) {
39
+ onChange(nextItem.id);
40
+ }
41
+ const nextRef = buttonRefs[nextIdx];
42
+ if (nextRef) {
43
+ nextRef.current?.focus();
44
+ }
45
+ }
46
+ }, [items, selectedItemId, onChange, buttonRefs]);
47
+ return _jsxs(_Fragment, {
48
+ children: [label && _jsx("p", {
49
+ id: labelDomId,
50
+ className: 'uikit_ButtonRadioGroup_label',
51
+ children: label
52
+ }), _jsx("div", {
53
+ className: 'uikit_ButtonRadioGroup_cont',
54
+ role: 'radiogroup',
55
+ "aria-labelledby": label ? labelDomId : undefined,
56
+ "aria-label": !label && groupA11yLabel ? groupA11yLabel : undefined,
57
+ children: items.map((item, idx) => _jsx("button", {
58
+ ref: buttonRefs[idx],
59
+ type: 'button',
60
+ className: 'uikit_ButtonRadioGroup_btn' + (item.id === selectedItemId ? ' uikit_ButtonRadioGroup_btn--checked' : '') + (idx === items.length - 1 ? ' uikit_ButtonRadioGroup_btn--last' : '') + (idx > 0 && idx < items.length - 1 ? ' uikit_ButtonRadioGroup_btn--mid' : ''),
61
+ tabIndex: item.id === selectedItemId ? 0 : -1,
62
+ role: 'radio',
63
+ "aria-label": item.a11yLabel || undefined,
64
+ "aria-checked": item.id === selectedItemId,
65
+ title: item.a11yLabel,
66
+ onClick: () => {
67
+ if (item.id !== selectedItemId) {
68
+ onChange(item.id);
69
+ }
70
+ },
71
+ onKeyDown: handleKeyDown,
72
+ children: item.contents
73
+ }, item.id))
74
+ })]
75
+ });
76
+ }
@@ -0,0 +1,39 @@
1
+ .uikit_ButtonRadioGroup_label {
2
+ margin: 0 0 0.5em;
3
+ color: #848484;
4
+ }
5
+
6
+ .uikit_ButtonRadioGroup_cont {
7
+ display: flex;
8
+ align-items: center;
9
+ }
10
+
11
+ .uikit_ButtonRadioGroup_btn {
12
+ display: block;
13
+ appearance: none;
14
+ border: 1px solid #bcbcbc;
15
+ border-right: 0;
16
+ color: inherit;
17
+ background-color: transparent;
18
+ border-radius: 5px 0 0 5px;
19
+ font-family: inherit;
20
+ font-size: 1em;
21
+ margin: 0;
22
+ padding: 0.6em;
23
+ cursor: pointer;
24
+ position: relative;
25
+ outline-offset: 1px;
26
+ }
27
+
28
+ .uikit_ButtonRadioGroup_btn--checked {
29
+ background-color: #ddd;
30
+ }
31
+
32
+ .uikit_ButtonRadioGroup_btn--last {
33
+ border-radius: 0 5px 5px 0;
34
+ border-right: 1px solid #bcbcbc;
35
+ }
36
+
37
+ .uikit_ButtonRadioGroup_btn--mid {
38
+ border-radius: 0;
39
+ }
@@ -0,0 +1,15 @@
1
+ import { type JSX } from 'react';
2
+ import './styles.css';
3
+ type Props = {
4
+ /**
5
+ * Variant of error message.
6
+ *
7
+ * "standalone": a message which is not a part of any other widget.
8
+ * "attached": a message which is a part of another widget.
9
+ */
10
+ variant: 'standalone' | 'attached';
11
+ /** Text of error message */
12
+ children: string;
13
+ };
14
+ export declare function ErrorMessage(props: Props): JSX.Element;
15
+ export {};
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import './styles.css';
3
+ export function ErrorMessage(props) {
4
+ const {
5
+ variant,
6
+ children
7
+ } = props;
8
+ const className = 'uikit_ErrorMessage' + (variant === 'standalone' ? ' uikit_ErrorMessage--standalone' : '');
9
+ return _jsx("p", {
10
+ className: className,
11
+ children: children
12
+ });
13
+ }
@@ -0,0 +1,10 @@
1
+ .uikit_ErrorMessage {
2
+ font-size: 0.85em;
3
+ color: #a7241b;
4
+ margin: 0.5em 0 0;
5
+ }
6
+
7
+
8
+ .uikit_ErrorMessage--standalone {
9
+ margin: 1.8em 0 0;
10
+ }
@@ -0,0 +1,28 @@
1
+ import { type JSX } from 'react';
2
+ import './styles.css';
3
+ type Props = {
4
+ /** Type of the inner input element */
5
+ type?: 'text' | 'email' | 'password';
6
+ /** Label */
7
+ label: string;
8
+ /** autocomplete attribute */
9
+ autocompleteAttribute?: string;
10
+ /** Width in any CSS length value */
11
+ width?: string;
12
+ /** Error message */
13
+ errorMessage?: string;
14
+ /** Controlled value */
15
+ value: string;
16
+ /** Ref */
17
+ ref?: React.RefObject<HTMLInputElement | null>;
18
+ /** onChange callback */
19
+ onChange: (newValue: string) => unknown;
20
+ /** Optional handler of Tab key press */
21
+ onTab?: () => unknown;
22
+ /** Optional handler of Shift + Tab key press */
23
+ onShiftTab?: () => unknown;
24
+ /** Optional handler of the input onBlur event */
25
+ onBlur?: () => unknown;
26
+ };
27
+ export declare function InputField(props: Props): JSX.Element;
28
+ export {};
@@ -0,0 +1,85 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useCallback, useEffect } from 'react';
3
+ import './styles.css';
4
+ import processKeyDown from './process-key-down.js';
5
+ export function InputField(props) {
6
+ const {
7
+ type = 'text',
8
+ label,
9
+ autocompleteAttribute,
10
+ width = '100%',
11
+ errorMessage,
12
+ value,
13
+ ref,
14
+ onChange,
15
+ onTab,
16
+ onShiftTab,
17
+ onBlur
18
+ } = props;
19
+ const [isCapslockOn, setIsCapslockOn] = useState(false);
20
+ const handleChange = useCallback(e => onChange(e.target.value), [onChange]);
21
+ const handleKeyDown = useCallback(e => processKeyDown(e, onTab, onShiftTab, onBlur), [onTab, onShiftTab, onBlur]);
22
+ useEffect(() => {
23
+ function detectCapsLock(e) {
24
+ if (e.getModifierState('CapsLock')) {
25
+ if (!isCapslockOn) setIsCapslockOn(true);
26
+ return;
27
+ }
28
+ if (isCapslockOn) setIsCapslockOn(false);
29
+ }
30
+ // We need to listen for both keydown and keyup events so that we can detect presses
31
+ // of the cap lock key itself. Usually browsers dispatch a keydown event when a
32
+ // key is pressed down and a keyup event when it’s released, but for the caps
33
+ // lock key only one of these events is dispatched on each press.
34
+ //
35
+ // All browsers dispatch only a keydown event when the caps lock key is pressed to
36
+ // turn caps lock on. Firefox also dispatches only a keydown event when the caps lock
37
+ // key is pressed to turn caps lock off, but Chrome, Safari, and Edge dispatch only
38
+ // a keyup event instead (in effect, they act as if caps lock is another shift key
39
+ // that’s being pressed down for the duration of caps lock being turned on).
40
+ window.addEventListener('keydown', detectCapsLock);
41
+ window.addEventListener('keyup', detectCapsLock);
42
+ return () => {
43
+ window.removeEventListener('keydown', detectCapsLock);
44
+ window.removeEventListener('keyup', detectCapsLock);
45
+ };
46
+ }, [isCapslockOn]);
47
+ return _jsxs("div", {
48
+ className: 'uikit_InputField_cont',
49
+ style: {
50
+ width
51
+ },
52
+ children: [_jsxs("label", {
53
+ className: 'uikit_InputField_innerCont',
54
+ children: [_jsx("span", {
55
+ className: 'uikit_InputField_label',
56
+ children: label
57
+ }), _jsx("input", {
58
+ ref: ref,
59
+ className: 'uikit_InputField_input',
60
+ type: type,
61
+ autoComplete: autocompleteAttribute,
62
+ value: value,
63
+ onChange: handleChange,
64
+ onKeyDown: handleKeyDown,
65
+ onBlur: onBlur || undefined
66
+ })]
67
+ }), type === 'password' && !!isCapslockOn && _jsxs("div", {
68
+ className: 'uikit_InputField_capslockMsg',
69
+ children: [_jsx("svg", {
70
+ width: '24',
71
+ height: '24',
72
+ viewBox: '0 0 24 24',
73
+ className: 'uikit_InputField_capslockIcon',
74
+ children: _jsx("path", {
75
+ d: 'M12 20.016q3.281 0 5.648-2.367t2.367-5.648-2.367-5.648-5.648-2.367-5.648\n 2.367-2.367 5.648 2.367 5.648 5.648 2.367zM12 2.016q4.125 0 7.055 2.93t2.93\n 7.055-2.93 7.055-7.055 2.93-7.055-2.93-2.93-7.055 2.93-7.055\n 7.055-2.93zM11.016 6.984h1.969v6h-1.969v-6zM11.016 15h1.969v2.016h-1.969v-2.016z'
76
+ })
77
+ }), _jsx("span", {
78
+ children: "CapsLock"
79
+ })]
80
+ }), !!errorMessage && _jsx("div", {
81
+ className: 'uikit_InputField_errorMsg',
82
+ children: errorMessage
83
+ })]
84
+ });
85
+ }
@@ -0,0 +1,2 @@
1
+ export declare const TAB_KEYCODE = 9;
2
+ export default function processKeyDown(e: Pick<React.KeyboardEvent, 'which' | 'shiftKey' | 'preventDefault'>, onTab: (() => unknown) | undefined, onShiftTab: (() => unknown) | undefined, onBlur: (() => unknown) | undefined): void;
@@ -0,0 +1,14 @@
1
+ export const TAB_KEYCODE = 9;
2
+ export default function processKeyDown(e, onTab, onShiftTab, onBlur) {
3
+ if (e.which === TAB_KEYCODE && onTab && !e.shiftKey) {
4
+ e.preventDefault();
5
+ onTab();
6
+ if (onBlur) onBlur();
7
+ return;
8
+ }
9
+ if (e.which === TAB_KEYCODE && onShiftTab && e.shiftKey) {
10
+ e.preventDefault();
11
+ onShiftTab();
12
+ if (onBlur) onBlur();
13
+ }
14
+ }
@@ -0,0 +1,48 @@
1
+ .uikit_InputField_cont:not(:first-child) {
2
+ margin-top: 1.5em;
3
+ }
4
+
5
+ .uikit_InputField_innerCont {
6
+ display: block;
7
+ box-sizing: border-box;
8
+ width: 100%;
9
+ }
10
+
11
+ .uikit_InputField_label {
12
+ display: block;
13
+ margin-bottom: 0.25em;
14
+ color: #848484;
15
+ width: 100%;
16
+ }
17
+
18
+ .uikit_InputField_input {
19
+ display: block;
20
+ box-sizing: border-box;
21
+ width: 100%;
22
+ border: 1px solid #bcbcbc;
23
+ border-radius: 5px;
24
+ font-size: 1.2em;
25
+ padding: 0.5em 1em;
26
+ color: #242629;
27
+ background-color: #fff;
28
+ font-family: inherit;
29
+ }
30
+
31
+ .uikit_InputField_capslockMsg {
32
+ display: flex;
33
+ gap: .5em;
34
+ align-items: center;
35
+ margin-top: 0.2em;
36
+ font-size: 0.85rem;
37
+ }
38
+
39
+ .uikit_InputField_capslockIcon {
40
+ color: #a7241b;
41
+ fill: currentColor;
42
+ }
43
+
44
+ .uikit_InputField_errorMsg {
45
+ color: #a7241b;
46
+ font-size: 0.85rem;
47
+ margin-top: 0.2em;
48
+ }
@@ -0,0 +1,6 @@
1
+ import type { JSX } from 'react';
2
+ type Props = {
3
+ selected: boolean;
4
+ };
5
+ export default function Icon(props: Props): JSX.Element;
6
+ export {};
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export default function Icon(props) {
3
+ const {
4
+ selected
5
+ } = props;
6
+ const className = 'uikit_PlainSelect_icon' + (selected ? '' : ' uikit_PlainSelect_icon--hidden');
7
+ return _jsx("svg", {
8
+ className: className,
9
+ width: '24',
10
+ height: '24',
11
+ viewBox: '0 0 24 24',
12
+ children: _jsx("path", {
13
+ d: 'M9 16.172l10.594-10.594 1.406 1.406-12 12-5.578-5.578 1.406-1.406z',
14
+ fill: '#24b23e'
15
+ })
16
+ });
17
+ }
@@ -0,0 +1,61 @@
1
+ import { type JSX } from 'react';
2
+ import './styles.css';
3
+ type Props = {
4
+ /**
5
+ * Label
6
+ */
7
+ label: string;
8
+ /**
9
+ * Optional. Flag to switch the multi-select mode.
10
+ * If provided and set to true, allows multiple selections.
11
+ * Defaults to false.
12
+ */
13
+ multiple?: boolean;
14
+ /**
15
+ * List items
16
+ */
17
+ items: PlainSelectItem[];
18
+ /**
19
+ * Currently selected item's ID (for single-select mode)
20
+ * or array of items' IDs (for multi-select mode).
21
+ *
22
+ * The component throws an Error if:
23
+ * - a single string value is provided in multi-select mode;
24
+ * - an array of string values is provided in single-select mode.
25
+ *
26
+ * In order to indicate that no item/items are selected:
27
+ * - for single-select mode: pass null;
28
+ * - for multi-select mode: pass an empty array or null.
29
+ */
30
+ selectedItemId: string | string[] | null;
31
+ /** Maximum height of list container set in any CSS length value (e.g. px, em, etc).
32
+ * If not provided, list container is as high as all its list items' height.
33
+ */
34
+ maxHeight?: string;
35
+ /**
36
+ * Callback function invoked when selected item/items change.
37
+ *
38
+ * @param payload In single-select mode: ID of newely selected item.
39
+ * In multi-select mode: Array of IDs of selected items after change.
40
+ * Might be an empty array as multi-select mode allows deselecting
41
+ * all items.
42
+ */
43
+ onChange: (payload: string | string[]) => unknown;
44
+ };
45
+ /** Item descriptor */
46
+ export type PlainSelectItem = {
47
+ /** Unique ID */
48
+ id: string;
49
+ /** Item's main title */
50
+ title: string;
51
+ /** Optional additional description */
52
+ description?: string;
53
+ };
54
+ /**
55
+ * Select element implementing Listbox pattern. Presents a list of options and allows
56
+ * a user to select one or more of them.
57
+ *
58
+ * Note: Can only be used in controlled mode.
59
+ */
60
+ export declare function PlainSelect(props: Props): JSX.Element;
61
+ export {};
@@ -0,0 +1,195 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useId, useCallback, useState, useMemo, useLayoutEffect, createRef } from 'react';
3
+ import './styles.css';
4
+ import Icon from './icon.js';
5
+ /**
6
+ * Select element implementing Listbox pattern. Presents a list of options and allows
7
+ * a user to select one or more of them.
8
+ *
9
+ * Note: Can only be used in controlled mode.
10
+ */
11
+ export function PlainSelect(props) {
12
+ const {
13
+ label,
14
+ multiple = false,
15
+ items,
16
+ selectedItemId,
17
+ maxHeight,
18
+ onChange
19
+ } = props;
20
+ const labelDomId = useId();
21
+ const [currFocusedIdx, setCurrFocusedIdx] = useState(() => {
22
+ if (multiple) {
23
+ if (Array.isArray(selectedItemId)) {
24
+ const firstId = selectedItemId[0];
25
+ if (firstId) {
26
+ const idx = items.findIndex(item => item.id === firstId);
27
+ if (idx >= 0) {
28
+ return idx;
29
+ } else {
30
+ return 0;
31
+ }
32
+ } else {
33
+ return 0;
34
+ }
35
+ } else {
36
+ return 0;
37
+ }
38
+ } else {
39
+ return 0;
40
+ }
41
+ });
42
+ const itemRefs = useMemo(() => items.map(() => createRef()), [items]);
43
+ const handleSingleSelectKeyDown = useCallback(event => {
44
+ const currSelectedItemIdx = items.findIndex(item => item.id === selectedItemId);
45
+ let nextSelectedItemIdx = currSelectedItemIdx;
46
+ switch (event.key) {
47
+ case 'ArrowDown':
48
+ event.preventDefault();
49
+ if (currSelectedItemIdx < items.length - 1) {
50
+ nextSelectedItemIdx = currSelectedItemIdx + 1;
51
+ }
52
+ break;
53
+ case 'ArrowUp':
54
+ event.preventDefault();
55
+ if (currSelectedItemIdx > 0) {
56
+ nextSelectedItemIdx = currSelectedItemIdx - 1;
57
+ }
58
+ }
59
+ if (currSelectedItemIdx !== nextSelectedItemIdx) {
60
+ const nextSelectedItem = items[nextSelectedItemIdx];
61
+ if (nextSelectedItem != null) {
62
+ onChange(nextSelectedItem.id);
63
+ itemRefs[nextSelectedItemIdx]?.current?.scrollIntoView({
64
+ behavior: 'smooth',
65
+ block: 'nearest'
66
+ });
67
+ }
68
+ }
69
+ }, [items, selectedItemId, itemRefs, onChange]);
70
+ const handleMultiSelectKeyDown = useCallback(event => {
71
+ switch (event.key) {
72
+ case 'ArrowDown':
73
+ {
74
+ event.preventDefault();
75
+ const nextFocusedIdx = currFocusedIdx + 1;
76
+ if (nextFocusedIdx <= items.length - 1) {
77
+ setCurrFocusedIdx(nextFocusedIdx);
78
+ }
79
+ break;
80
+ }
81
+ case 'ArrowUp':
82
+ {
83
+ event.preventDefault();
84
+ const nextFocusedIdx = currFocusedIdx - 1;
85
+ if (nextFocusedIdx >= 0) {
86
+ setCurrFocusedIdx(nextFocusedIdx);
87
+ }
88
+ break;
89
+ }
90
+ case ' ':
91
+ {
92
+ event.preventDefault();
93
+ const item = items[currFocusedIdx];
94
+ if (item) {
95
+ if (Array.isArray(selectedItemId)) {
96
+ if (selectedItemId.includes(item.id)) {
97
+ onChange(selectedItemId.filter(id => id !== item.id));
98
+ } else {
99
+ onChange(selectedItemId.concat(item.id));
100
+ }
101
+ } else {
102
+ onChange([item.id]);
103
+ }
104
+ }
105
+ break;
106
+ }
107
+ case 'a':
108
+ {
109
+ if (event.ctrlKey || event.metaKey) {
110
+ event.preventDefault();
111
+ if (Array.isArray(selectedItemId) && selectedItemId.length === items.length) {
112
+ onChange([]);
113
+ } else {
114
+ onChange(items.map(item => item.id));
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }, [currFocusedIdx, items, selectedItemId, onChange]);
120
+ const handleItemClick = useCallback(id => {
121
+ if (multiple) {
122
+ const itemIdx = items.findIndex(item => item.id === id);
123
+ if (itemIdx >= 0) {
124
+ setCurrFocusedIdx(itemIdx);
125
+ if (Array.isArray(selectedItemId)) {
126
+ if (selectedItemId.includes(id)) {
127
+ onChange(selectedItemId.filter(selectedId => selectedId !== id));
128
+ } else {
129
+ onChange(selectedItemId.concat(id));
130
+ }
131
+ } else {
132
+ onChange([id]);
133
+ }
134
+ }
135
+ } else if (id !== selectedItemId) {
136
+ onChange(id);
137
+ }
138
+ }, [multiple, selectedItemId, onChange, items]);
139
+ useLayoutEffect(() => {
140
+ if (multiple && itemRefs.some(ref => ref.current === document.activeElement)) {
141
+ itemRefs[currFocusedIdx]?.current?.focus();
142
+ }
143
+ }, [multiple, itemRefs, currFocusedIdx]);
144
+ if (multiple && !Array.isArray(selectedItemId) && selectedItemId != null) {
145
+ const msg = 'single string value in selectedItemId prop is not ' + 'acceptable for multi-select mode';
146
+ throw new Error(msg);
147
+ }
148
+ if (!multiple && Array.isArray(selectedItemId)) {
149
+ const msg = 'array in selectedItemId prop is not ' + 'acceptable for single-select mode';
150
+ throw new Error(msg);
151
+ }
152
+ return _jsxs(_Fragment, {
153
+ children: [_jsx("p", {
154
+ id: labelDomId,
155
+ className: 'uikit_PlainSelect_label',
156
+ children: label
157
+ }), _jsx("ul", {
158
+ className: 'uikit_PlainSelect_cont',
159
+ style: {
160
+ ...(maxHeight ? {
161
+ maxHeight,
162
+ overflow: 'auto'
163
+ } : {})
164
+ },
165
+ role: 'listbox',
166
+ "aria-labelledby": labelDomId,
167
+ "aria-multiselectable": multiple,
168
+ tabIndex: multiple ? -1 : 0,
169
+ onKeyDown: multiple ? undefined : handleSingleSelectKeyDown,
170
+ children: items.map((item, idx) => {
171
+ const isSelected = multiple ? selectedItemId != null && selectedItemId.includes(item.id) : selectedItemId != null && item.id === selectedItemId;
172
+ return _jsxs("li", {
173
+ ref: itemRefs[idx],
174
+ className: 'uikit_PlainSelect_itemCont',
175
+ role: 'option',
176
+ "aria-selected": isSelected,
177
+ tabIndex: multiple && currFocusedIdx === idx ? 0 : -1,
178
+ onClick: () => handleItemClick(item.id),
179
+ onKeyDown: multiple ? handleMultiSelectKeyDown : undefined,
180
+ children: [_jsx(Icon, {
181
+ selected: isSelected
182
+ }), _jsxs("div", {
183
+ className: 'uikit_PlainSelect_itemContent',
184
+ children: [_jsx("div", {
185
+ children: item.title
186
+ }), item.description && _jsx("div", {
187
+ className: 'uikit_PlainSelect_description',
188
+ children: item.description
189
+ })]
190
+ })]
191
+ }, item.id);
192
+ })
193
+ })]
194
+ });
195
+ }
@@ -0,0 +1,41 @@
1
+ .uikit_PlainSelect_label {
2
+ margin: 0;
3
+ color: #848484;
4
+ }
5
+
6
+ .uikit_PlainSelect_cont {
7
+ margin: 0.5em 0 0;
8
+ padding: 0.5em;
9
+ border: 1px solid #bcbcbc;
10
+ list-style-type: none;
11
+ border-radius: 0.25em;
12
+ }
13
+
14
+ .uikit_PlainSelect_itemCont {
15
+ display: flex;
16
+ align-items: flex-start;
17
+ gap: 0.5rem;
18
+ padding: 0.25em;
19
+ cursor: pointer;
20
+ }
21
+
22
+ .uikit_PlainSelect_icon {
23
+ flex-shrink: 0;
24
+ }
25
+
26
+ .uikit_PlainSelect_icon--hidden {
27
+ visibility: hidden;
28
+ }
29
+
30
+ .uikit_PlainSelect_itemContent {
31
+ border-bottom: 1px solid #ddd;
32
+ flex-basis: 100%;
33
+ flex-shrink: 1;
34
+ padding-bottom: 0.25em;
35
+ }
36
+
37
+ .uikit_PlainSelect_description {
38
+ margin-top: 0.5em;
39
+ color: #848484;
40
+ font-size: 87.5%;
41
+ }
@@ -0,0 +1,21 @@
1
+ import { type JSX } from 'react';
2
+ import './styles.css';
3
+ type Props = {
4
+ /** Button label */
5
+ label: string;
6
+ /**
7
+ * Tab index
8
+ * @default 0
9
+ */
10
+ tabIndex?: number;
11
+ /** Ref */
12
+ ref?: React.RefObject<HTMLButtonElement | null>;
13
+ /** onClick event handler */
14
+ onClick: (e: React.MouseEvent) => unknown;
15
+ /** Optional handler of Tab key press */
16
+ onTab?: () => unknown;
17
+ /** Optional handler of Shift + Tab key press */
18
+ onShiftTab?: () => unknown;
19
+ };
20
+ export declare function TextButton(props: Props): JSX.Element;
21
+ export {};
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback } from 'react';
3
+ import './styles.css';
4
+ import processKeyDown from './process-key-down.js';
5
+ export function TextButton(props) {
6
+ const {
7
+ label,
8
+ tabIndex = 0,
9
+ ref,
10
+ onClick,
11
+ onTab,
12
+ onShiftTab
13
+ } = props;
14
+ const handleClick = useCallback(e => onClick(e), [onClick]);
15
+ const handleKeyDown = useCallback(e => processKeyDown(e, onTab, onShiftTab), [onTab, onShiftTab]);
16
+ return _jsx("button", {
17
+ ref: ref,
18
+ type: 'button',
19
+ className: 'uikit_TextButton_btn',
20
+ tabIndex: tabIndex,
21
+ onClick: handleClick,
22
+ onKeyDown: handleKeyDown,
23
+ children: label
24
+ });
25
+ }
@@ -0,0 +1,2 @@
1
+ export declare const TAB_KEYCODE = 9;
2
+ export default function processKeyDown(e: Pick<React.KeyboardEvent, 'which' | 'shiftKey' | 'preventDefault'>, onTab: (() => unknown) | undefined, onShiftTab: (() => unknown) | undefined): void;
@@ -0,0 +1,12 @@
1
+ export const TAB_KEYCODE = 9;
2
+ export default function processKeyDown(e, onTab, onShiftTab) {
3
+ if (e.which === TAB_KEYCODE && onTab && !e.shiftKey) {
4
+ e.preventDefault();
5
+ onTab();
6
+ return;
7
+ }
8
+ if (e.which === TAB_KEYCODE && onShiftTab && e.shiftKey) {
9
+ e.preventDefault();
10
+ onShiftTab();
11
+ }
12
+ }
@@ -0,0 +1,14 @@
1
+ .uikit_TextButton_btn {
2
+ display: inline;
3
+ appearance: none;
4
+ border: 0;
5
+ border-bottom: 1px solid #678983;
6
+ color: #678983;
7
+ background-color: transparent;
8
+ font-family: inherit;
9
+ font-size: 1em;
10
+ margin: 0;
11
+ padding: 0;
12
+ cursor: pointer;
13
+ outline-offset: 3px;
14
+ }
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@peassoft/mnr-web-ui-kit",
3
+ "version": "0.1.0",
4
+ "description": "Peassoft web UI kit for MNR web applications",
5
+ "type": "module",
6
+ "exports": {
7
+ "./*": "./dist/*"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "dev": "npm run storybook",
14
+ "test": "stylelint './src/**/*.css' && eslint ./ && npm run tsc && jest",
15
+ "tsc": "tsc --noEmit",
16
+ "build": "del-cli ./compiled && del-cli ./dist && tsc -p tsconfig-prod.json && babel ./compiled --out-dir ./dist && cpy './src/**/*.css' './dist' && cpy './compiled/**/*.d.ts' './dist'",
17
+ "storybook": "storybook dev -p 9001 --no-open",
18
+ "build-storybook": "storybook build"
19
+ },
20
+ "browserslist": [
21
+ "extends @memnrev/browserslist-config"
22
+ ],
23
+ "jest": {
24
+ "verbose": false,
25
+ "coverageDirectory": "coverage",
26
+ "transform": {
27
+ "^.+\\.(ts|js)$": [
28
+ "<rootDir>/node_modules/ts-jest",
29
+ {
30
+ "useESM": true
31
+ }
32
+ ]
33
+ },
34
+ "transformIgnorePatterns": [
35
+ "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$"
36
+ ],
37
+ "extensionsToTreatAsEsm": [
38
+ ".ts"
39
+ ],
40
+ "moduleNameMapper": {
41
+ "^(\\.{1,2}/.*)\\.js$": "$1"
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "@babel/cli": "^7.22.15",
46
+ "@babel/core": "^7.22.17",
47
+ "@babel/preset-env": "^7.22.15",
48
+ "@memnrev/browserslist-config": "^0.1.0",
49
+ "@memnrev/eslint-v9-config": "^0.1.1",
50
+ "@storybook/addon-essentials": "^8.0.5",
51
+ "@storybook/addon-interactions": "^8.0.5",
52
+ "@storybook/addon-links": "^8.0.5",
53
+ "@storybook/addon-onboarding": "^8.0.5",
54
+ "@storybook/addon-webpack5-compiler-babel": "^3.0.3",
55
+ "@storybook/blocks": "^8.0.5",
56
+ "@storybook/react": "^8.0.5",
57
+ "@storybook/react-webpack5": "^8.0.5",
58
+ "@storybook/test": "^8.0.5",
59
+ "@types/jest": "^29.5.4",
60
+ "@types/react": "^19.0.2",
61
+ "cpy-cli": "^5.0.0",
62
+ "del-cli": "^6.0.0",
63
+ "eslint": "^9.8.0",
64
+ "eslint-plugin-storybook": "^0.11.0",
65
+ "jest": "^29.4.1",
66
+ "react": "^19.0.0",
67
+ "react-dom": "^19.0.0",
68
+ "storybook": "^8.0.5",
69
+ "stylelint": "^16.2.1",
70
+ "ts-jest": "^29.1.1",
71
+ "ts-loader": "^9.4.4",
72
+ "typescript": "^5.2.2"
73
+ },
74
+ "peerDependencies": {
75
+ "react": ">= 19 < 20",
76
+ "react-dom": ">= 19 < 20"
77
+ }
78
+ }