@stack-spot/citric-react 0.35.1 → 0.37.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/citric.css +2844 -2832
- package/dist/components/Accordion.d.ts +1 -1
- package/dist/components/Accordion.js +1 -1
- package/dist/components/Alert.d.ts +1 -1
- package/dist/components/Alert.js +1 -1
- package/dist/components/AsyncContent.d.ts +1 -1
- package/dist/components/AsyncContent.js +1 -1
- package/dist/components/Avatar.d.ts +1 -1
- package/dist/components/Avatar.js +1 -1
- package/dist/components/AvatarGroup.d.ts +1 -1
- package/dist/components/AvatarGroup.js +1 -1
- package/dist/components/Badge.d.ts +1 -1
- package/dist/components/Badge.js +1 -1
- package/dist/components/Blockquote.d.ts +1 -1
- package/dist/components/Blockquote.js +1 -1
- package/dist/components/Breadcrumb.d.ts +1 -1
- package/dist/components/Breadcrumb.js +1 -1
- package/dist/components/Button.d.ts +1 -1
- package/dist/components/Button.js +1 -1
- package/dist/components/ButtonLink.d.ts +1 -1
- package/dist/components/ButtonLink.js +1 -1
- package/dist/components/Card.d.ts +1 -1
- package/dist/components/Card.js +1 -1
- package/dist/components/Checkbox.d.ts +1 -1
- package/dist/components/Checkbox.js +1 -1
- package/dist/components/CheckboxGroup.d.ts +1 -1
- package/dist/components/CheckboxGroup.js +1 -1
- package/dist/components/Circle.d.ts +1 -1
- package/dist/components/Circle.js +1 -1
- package/dist/components/Divider.d.ts +1 -1
- package/dist/components/Divider.js +1 -1
- package/dist/components/ErrorBoundary.d.ts +1 -1
- package/dist/components/ErrorBoundary.js +1 -1
- package/dist/components/ErrorMessage.d.ts +1 -1
- package/dist/components/ErrorMessage.js +1 -1
- package/dist/components/FallbackBoundary.d.ts +1 -1
- package/dist/components/FallbackBoundary.js +1 -1
- package/dist/components/Favorite.d.ts +1 -1
- package/dist/components/Favorite.js +1 -1
- package/dist/components/FieldGroup.d.ts +1 -1
- package/dist/components/FieldGroup.js +1 -1
- package/dist/components/Form.d.ts +2 -2
- package/dist/components/Form.js +1 -1
- package/dist/components/FormGroup.d.ts +1 -1
- package/dist/components/FormGroup.js +1 -1
- package/dist/components/Icon.d.ts +1 -1
- package/dist/components/Icon.js +1 -1
- package/dist/components/IconBox.d.ts +3 -3
- package/dist/components/IconBox.js +1 -1
- package/dist/components/ImageBox.d.ts +3 -3
- package/dist/components/ImageBox.js +1 -1
- package/dist/components/ImageWithFallback.d.ts +1 -1
- package/dist/components/ImageWithFallback.js +1 -1
- package/dist/components/Input.d.ts +1 -1
- package/dist/components/Input.js +1 -1
- package/dist/components/Link.d.ts +1 -1
- package/dist/components/Link.js +1 -1
- package/dist/components/LoadingPanel.d.ts +1 -1
- package/dist/components/LoadingPanel.js +1 -1
- package/dist/components/MenuOverlay/Menu.d.ts +1 -1
- package/dist/components/MenuOverlay/Menu.js +1 -1
- package/dist/components/MenuOverlay/index.d.ts +1 -1
- package/dist/components/MenuOverlay/index.js +1 -1
- package/dist/components/Overlay/index.d.ts +1 -1
- package/dist/components/Overlay/index.js +1 -1
- package/dist/components/Pagination.d.ts +1 -1
- package/dist/components/Pagination.js +1 -1
- package/dist/components/ProgressBar.d.ts +1 -1
- package/dist/components/ProgressBar.js +1 -1
- package/dist/components/ProgressCircular.d.ts +1 -1
- package/dist/components/ProgressCircular.js +1 -1
- package/dist/components/RadioGroup.d.ts +1 -1
- package/dist/components/RadioGroup.js +1 -1
- package/dist/components/Rating.d.ts +17 -3
- package/dist/components/Rating.d.ts.map +1 -1
- package/dist/components/Rating.js +11 -3
- package/dist/components/Rating.js.map +1 -1
- package/dist/components/Select/MultiSelect.d.ts +1 -1
- package/dist/components/Select/MultiSelect.js +1 -1
- package/dist/components/Select/RichSelect.d.ts +1 -1
- package/dist/components/Select/RichSelect.js +1 -1
- package/dist/components/Select/SimpleSelect.d.ts +1 -1
- package/dist/components/Select/SimpleSelect.js +1 -1
- package/dist/components/Select/index.d.ts +1 -1
- package/dist/components/Select/index.js +1 -1
- package/dist/components/SelectBox.d.ts +1 -1
- package/dist/components/SelectBox.js +1 -1
- package/dist/components/Skeleton.d.ts +1 -1
- package/dist/components/Skeleton.js +1 -1
- package/dist/components/Slider.d.ts +1 -1
- package/dist/components/Slider.js +1 -1
- package/dist/components/SmartTable.d.ts +1 -1
- package/dist/components/SmartTable.js +1 -1
- package/dist/components/Stepper.d.ts +1 -1
- package/dist/components/Stepper.js +1 -1
- package/dist/components/Table.d.ts +3 -3
- package/dist/components/Table.js +1 -1
- package/dist/components/Tabs/index.d.ts +1 -1
- package/dist/components/Tabs/index.js +1 -1
- package/dist/components/Textarea.d.ts +1 -1
- package/dist/components/Textarea.js +1 -1
- package/dist/components/Tooltip.d.ts +1 -1
- package/dist/components/Tooltip.js +1 -1
- package/dist/context/CitricProvider.d.ts +1 -1
- package/dist/context/CitricProvider.js +1 -1
- package/dist/overlay.js +1 -1
- package/dist/theme.css +415 -415
- package/package.json +7 -6
- package/scripts/build-css.ts +49 -49
- package/src/components/Accordion.tsx +130 -130
- package/src/components/Alert.tsx +24 -24
- package/src/components/AsyncContent.tsx +70 -70
- package/src/components/Avatar.tsx +45 -45
- package/src/components/AvatarGroup.tsx +49 -49
- package/src/components/Badge.tsx +47 -47
- package/src/components/Blockquote.tsx +18 -18
- package/src/components/Breadcrumb.tsx +33 -33
- package/src/components/Button.tsx +105 -105
- package/src/components/ButtonLink.tsx +45 -45
- package/src/components/Card.tsx +68 -68
- package/src/components/Checkbox.tsx +51 -51
- package/src/components/CheckboxGroup.tsx +152 -152
- package/src/components/Circle.tsx +43 -43
- package/src/components/CitricComponent.ts +47 -47
- package/src/components/Divider.tsx +24 -24
- package/src/components/ErrorBoundary.tsx +75 -75
- package/src/components/ErrorMessage.tsx +11 -11
- package/src/components/FallbackBoundary.tsx +40 -40
- package/src/components/Favorite.tsx +57 -57
- package/src/components/FieldGroup.tsx +46 -46
- package/src/components/Form.tsx +36 -36
- package/src/components/FormGroup.tsx +57 -57
- package/src/components/Icon.tsx +35 -35
- package/src/components/IconBox.tsx +134 -134
- package/src/components/ImageBox.tsx +125 -125
- package/src/components/ImageWithFallback.tsx +65 -65
- package/src/components/Input.tsx +49 -49
- package/src/components/Link.tsx +55 -55
- package/src/components/LoadingPanel.tsx +8 -8
- package/src/components/MenuOverlay/Menu.tsx +158 -158
- package/src/components/MenuOverlay/context.ts +20 -20
- package/src/components/MenuOverlay/index.tsx +55 -55
- package/src/components/MenuOverlay/keyboard.ts +60 -60
- package/src/components/MenuOverlay/types.ts +171 -171
- package/src/components/Overlay/context.ts +10 -10
- package/src/components/Overlay/index.tsx +164 -164
- package/src/components/Overlay/types.ts +70 -70
- package/src/components/Pagination.tsx +113 -113
- package/src/components/ProgressBar.tsx +45 -45
- package/src/components/ProgressCircular.tsx +45 -45
- package/src/components/RadioGroup.tsx +146 -146
- package/src/components/Rating.tsx +98 -35
- package/src/components/Select/MultiSelect.tsx +217 -217
- package/src/components/Select/RichSelect.tsx +128 -128
- package/src/components/Select/SimpleSelect.tsx +73 -73
- package/src/components/Select/hooks.ts +133 -133
- package/src/components/Select/index.tsx +35 -35
- package/src/components/Select/types.ts +134 -134
- package/src/components/SelectBox.tsx +167 -167
- package/src/components/Skeleton.tsx +53 -53
- package/src/components/Slider.tsx +89 -89
- package/src/components/SmartTable.tsx +227 -227
- package/src/components/Stepper.tsx +163 -163
- package/src/components/Table.tsx +234 -234
- package/src/components/Tabs/TabController.ts +54 -54
- package/src/components/Tabs/index.tsx +87 -87
- package/src/components/Tabs/types.ts +54 -54
- package/src/components/Tabs/utils.ts +6 -6
- package/src/components/Text.ts +111 -111
- package/src/components/Textarea.tsx +27 -27
- package/src/components/Tooltip.tsx +72 -72
- package/src/components/layout.tsx +101 -101
- package/src/context/CitricContext.tsx +4 -4
- package/src/context/CitricProvider.tsx +14 -14
- package/src/context/hooks.ts +6 -6
- package/src/index.ts +58 -58
- package/src/overlay.ts +341 -341
- package/src/types.ts +216 -216
- package/src/utils/ValueController.ts +28 -28
- package/src/utils/acessibility.ts +92 -92
- package/src/utils/checkbox.ts +121 -121
- package/src/utils/css.ts +119 -119
- package/src/utils/options.ts +9 -9
- package/src/utils/radio.ts +93 -93
- package/src/utils/react.ts +6 -6
- package/tsconfig.json +10 -10
|
@@ -1,158 +1,158 @@
|
|
|
1
|
-
import { listToClass } from '@stack-spot/portal-theme'
|
|
2
|
-
import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
|
|
3
|
-
import { createElement, useEffect, useMemo } from 'react'
|
|
4
|
-
import { ValueController } from '../../utils/ValueController'
|
|
5
|
-
import { CitricComponent } from '../CitricComponent'
|
|
6
|
-
import { IconButton } from '../IconBox'
|
|
7
|
-
import { useOverlayController } from '../Overlay/context'
|
|
8
|
-
import { MenuProvider, useMenuController, useMenuState } from './context'
|
|
9
|
-
import { keyboardNavigation } from './keyboard'
|
|
10
|
-
import { MenuAction as MenuActionI, MenuCollapsible, MenuItem, MenuProps, MenuSection as MenuSectionI, MenuState } from './types'
|
|
11
|
-
|
|
12
|
-
// Arbitrary time (ms) to wait before running a function that needs the view to be updated with the next state value.
|
|
13
|
-
const RENDER_DELAY = 20
|
|
14
|
-
|
|
15
|
-
function Submenu({ children, label, className, icon, iconRight, style }: MenuCollapsible) {
|
|
16
|
-
const controller = useMenuController()
|
|
17
|
-
return (
|
|
18
|
-
<button
|
|
19
|
-
className={listToClass([className, 'submenu'])}
|
|
20
|
-
style={style}
|
|
21
|
-
onClick={(e) => {
|
|
22
|
-
e.stopPropagation()
|
|
23
|
-
const menu = e.target instanceof HTMLElement ? e.target.closest('[data-citric="menu"]') : undefined
|
|
24
|
-
controller?.setValue({ items: children, label, parent: controller?.getValue() })
|
|
25
|
-
const isOpenedWithMouse = e.detail > 0
|
|
26
|
-
if (!isOpenedWithMouse) {
|
|
27
|
-
setTimeout(() => {
|
|
28
|
-
const firstFocusable = menu?.querySelector('a, button')
|
|
29
|
-
if (firstFocusable instanceof HTMLElement) firstFocusable.focus()
|
|
30
|
-
}, RENDER_DELAY)
|
|
31
|
-
}
|
|
32
|
-
}}
|
|
33
|
-
>
|
|
34
|
-
{icon}
|
|
35
|
-
<span>{label}</span>
|
|
36
|
-
{iconRight}
|
|
37
|
-
</button>
|
|
38
|
-
)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function MenuSection({ children, label, className, style }: MenuSectionI) {
|
|
42
|
-
return (
|
|
43
|
-
<section className={className} style={style}>
|
|
44
|
-
{label && <h6>{label}</h6>}
|
|
45
|
-
<nav>
|
|
46
|
-
<MenuItems items={children} />
|
|
47
|
-
</nav>
|
|
48
|
-
{!label && <hr />}
|
|
49
|
-
</section>
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function MenuAction({ label, active, href, icon, iconRight, className, onClick, ...props }: MenuActionI) {
|
|
54
|
-
const overlayController = useOverlayController()
|
|
55
|
-
const children = <>
|
|
56
|
-
{icon}
|
|
57
|
-
{typeof label === 'string' ? <span>{label}</span> : label.element}
|
|
58
|
-
{iconRight}
|
|
59
|
-
</>
|
|
60
|
-
return createElement(
|
|
61
|
-
href ? 'a' : 'button',
|
|
62
|
-
{
|
|
63
|
-
href,
|
|
64
|
-
'aria-label': typeof label === 'string' ? label : label.id,
|
|
65
|
-
className: listToClass([className, active && 'active']),
|
|
66
|
-
onClick: (e) => {
|
|
67
|
-
overlayController?.close()
|
|
68
|
-
onClick?.(e)
|
|
69
|
-
},
|
|
70
|
-
...props,
|
|
71
|
-
},
|
|
72
|
-
children,
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function hasSections(items: MenuItem[]) {
|
|
77
|
-
return items.some(i => 'children' in i && i.children.length && i.type === 'section')
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function hasSubmenus(items: MenuItem[]) {
|
|
81
|
-
return items.some(i => 'children' in i && i.children.length && i.type === 'collapsible')
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function MenuItems({ items }: { items: MenuItem[] }) {
|
|
85
|
-
return useMemo(() => items.map((item, index) => {
|
|
86
|
-
if ('children' in item && item.type === 'section') return <MenuSection key={item.label || index} {...item} />
|
|
87
|
-
if ('children' in item && item.type === 'collapsible') return <Submenu key={item.label || index} {...item} />
|
|
88
|
-
return <MenuAction key={(typeof item.label === 'string' ? item.label : item.label?.id) || index} {...item} />
|
|
89
|
-
}), [items])
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* TODO: make the height changes animated.
|
|
94
|
-
*/
|
|
95
|
-
export function Menu(
|
|
96
|
-
{ items, appearance, bgLevel, header, roundedItems, showBorders, showShadows, spaced, className, onKeyDown, ...props }: MenuProps,
|
|
97
|
-
) {
|
|
98
|
-
const controller = useMemo(() => new ValueController<MenuState>({ items }), [])
|
|
99
|
-
const current = useMenuState(controller)
|
|
100
|
-
const { sectioned, collapsible } = useMemo(
|
|
101
|
-
() => ({ sectioned: hasSections(current.items), collapsible: hasSubmenus(current.items) }),
|
|
102
|
-
[current.items],
|
|
103
|
-
)
|
|
104
|
-
const tag = header || sectioned || collapsible ? 'div' : 'nav'
|
|
105
|
-
const t = useTranslate(dictionary)
|
|
106
|
-
|
|
107
|
-
useEffect(() => {
|
|
108
|
-
if (items !== controller.getValue().items) controller.setValue({ items })
|
|
109
|
-
}, [items])
|
|
110
|
-
|
|
111
|
-
return (
|
|
112
|
-
<MenuProvider value={controller}>
|
|
113
|
-
<CitricComponent
|
|
114
|
-
tag={tag}
|
|
115
|
-
component="menu"
|
|
116
|
-
className={listToClass([
|
|
117
|
-
className, appearance, roundedItems && 'rounded-items', showBorders && 'bordered', showShadows === false && 'no-shadow',
|
|
118
|
-
spaced && 'spaced', bgLevel && `bg-${bgLevel}`,
|
|
119
|
-
])}
|
|
120
|
-
onKeyDown={(e: any) => {
|
|
121
|
-
keyboardNavigation(e)
|
|
122
|
-
onKeyDown?.(e)
|
|
123
|
-
}}
|
|
124
|
-
{...props}
|
|
125
|
-
>
|
|
126
|
-
{header && <header>{header}</header>}
|
|
127
|
-
{current.parent && (
|
|
128
|
-
<div className="back-button">
|
|
129
|
-
<IconButton
|
|
130
|
-
icon="ArrowLeft"
|
|
131
|
-
aria-label={t.goBack}
|
|
132
|
-
onClick={(e) => {
|
|
133
|
-
e.stopPropagation()
|
|
134
|
-
const menu = e.target instanceof HTMLElement ? e.target.closest('[data-citric="menu"]') : undefined
|
|
135
|
-
if (current.parent) controller.setValue(current.parent)
|
|
136
|
-
setTimeout(() => {
|
|
137
|
-
const lastItem = Array.from(menu?.querySelectorAll('a, button') ?? []).find(el => el.textContent === current.label)
|
|
138
|
-
if (lastItem instanceof HTMLElement) lastItem.focus()
|
|
139
|
-
}, RENDER_DELAY)
|
|
140
|
-
}}
|
|
141
|
-
/>
|
|
142
|
-
<span>{current.label || t.goBack}</span>
|
|
143
|
-
</div>
|
|
144
|
-
)}
|
|
145
|
-
{sectioned ? <MenuItems items={current.items} /> : <nav><MenuItems items={current.items} /></nav>}
|
|
146
|
-
</CitricComponent>
|
|
147
|
-
</MenuProvider>
|
|
148
|
-
)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const dictionary = {
|
|
152
|
-
en: {
|
|
153
|
-
goBack: 'Go back',
|
|
154
|
-
},
|
|
155
|
-
pt: {
|
|
156
|
-
goBack: 'Voltar',
|
|
157
|
-
},
|
|
158
|
-
} satisfies Dictionary
|
|
1
|
+
import { listToClass } from '@stack-spot/portal-theme'
|
|
2
|
+
import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
|
|
3
|
+
import { createElement, useEffect, useMemo } from 'react'
|
|
4
|
+
import { ValueController } from '../../utils/ValueController'
|
|
5
|
+
import { CitricComponent } from '../CitricComponent'
|
|
6
|
+
import { IconButton } from '../IconBox'
|
|
7
|
+
import { useOverlayController } from '../Overlay/context'
|
|
8
|
+
import { MenuProvider, useMenuController, useMenuState } from './context'
|
|
9
|
+
import { keyboardNavigation } from './keyboard'
|
|
10
|
+
import { MenuAction as MenuActionI, MenuCollapsible, MenuItem, MenuProps, MenuSection as MenuSectionI, MenuState } from './types'
|
|
11
|
+
|
|
12
|
+
// Arbitrary time (ms) to wait before running a function that needs the view to be updated with the next state value.
|
|
13
|
+
const RENDER_DELAY = 20
|
|
14
|
+
|
|
15
|
+
function Submenu({ children, label, className, icon, iconRight, style }: MenuCollapsible) {
|
|
16
|
+
const controller = useMenuController()
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
className={listToClass([className, 'submenu'])}
|
|
20
|
+
style={style}
|
|
21
|
+
onClick={(e) => {
|
|
22
|
+
e.stopPropagation()
|
|
23
|
+
const menu = e.target instanceof HTMLElement ? e.target.closest('[data-citric="menu"]') : undefined
|
|
24
|
+
controller?.setValue({ items: children, label, parent: controller?.getValue() })
|
|
25
|
+
const isOpenedWithMouse = e.detail > 0
|
|
26
|
+
if (!isOpenedWithMouse) {
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
const firstFocusable = menu?.querySelector('a, button')
|
|
29
|
+
if (firstFocusable instanceof HTMLElement) firstFocusable.focus()
|
|
30
|
+
}, RENDER_DELAY)
|
|
31
|
+
}
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
{icon}
|
|
35
|
+
<span>{label}</span>
|
|
36
|
+
{iconRight}
|
|
37
|
+
</button>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function MenuSection({ children, label, className, style }: MenuSectionI) {
|
|
42
|
+
return (
|
|
43
|
+
<section className={className} style={style}>
|
|
44
|
+
{label && <h6>{label}</h6>}
|
|
45
|
+
<nav>
|
|
46
|
+
<MenuItems items={children} />
|
|
47
|
+
</nav>
|
|
48
|
+
{!label && <hr />}
|
|
49
|
+
</section>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function MenuAction({ label, active, href, icon, iconRight, className, onClick, ...props }: MenuActionI) {
|
|
54
|
+
const overlayController = useOverlayController()
|
|
55
|
+
const children = <>
|
|
56
|
+
{icon}
|
|
57
|
+
{typeof label === 'string' ? <span>{label}</span> : label.element}
|
|
58
|
+
{iconRight}
|
|
59
|
+
</>
|
|
60
|
+
return createElement(
|
|
61
|
+
href ? 'a' : 'button',
|
|
62
|
+
{
|
|
63
|
+
href,
|
|
64
|
+
'aria-label': typeof label === 'string' ? label : label.id,
|
|
65
|
+
className: listToClass([className, active && 'active']),
|
|
66
|
+
onClick: (e) => {
|
|
67
|
+
overlayController?.close()
|
|
68
|
+
onClick?.(e)
|
|
69
|
+
},
|
|
70
|
+
...props,
|
|
71
|
+
},
|
|
72
|
+
children,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function hasSections(items: MenuItem[]) {
|
|
77
|
+
return items.some(i => 'children' in i && i.children.length && i.type === 'section')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasSubmenus(items: MenuItem[]) {
|
|
81
|
+
return items.some(i => 'children' in i && i.children.length && i.type === 'collapsible')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function MenuItems({ items }: { items: MenuItem[] }) {
|
|
85
|
+
return useMemo(() => items.map((item, index) => {
|
|
86
|
+
if ('children' in item && item.type === 'section') return <MenuSection key={item.label || index} {...item} />
|
|
87
|
+
if ('children' in item && item.type === 'collapsible') return <Submenu key={item.label || index} {...item} />
|
|
88
|
+
return <MenuAction key={(typeof item.label === 'string' ? item.label : item.label?.id) || index} {...item} />
|
|
89
|
+
}), [items])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* TODO: make the height changes animated.
|
|
94
|
+
*/
|
|
95
|
+
export function Menu(
|
|
96
|
+
{ items, appearance, bgLevel, header, roundedItems, showBorders, showShadows, spaced, className, onKeyDown, ...props }: MenuProps,
|
|
97
|
+
) {
|
|
98
|
+
const controller = useMemo(() => new ValueController<MenuState>({ items }), [])
|
|
99
|
+
const current = useMenuState(controller)
|
|
100
|
+
const { sectioned, collapsible } = useMemo(
|
|
101
|
+
() => ({ sectioned: hasSections(current.items), collapsible: hasSubmenus(current.items) }),
|
|
102
|
+
[current.items],
|
|
103
|
+
)
|
|
104
|
+
const tag = header || sectioned || collapsible ? 'div' : 'nav'
|
|
105
|
+
const t = useTranslate(dictionary)
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (items !== controller.getValue().items) controller.setValue({ items })
|
|
109
|
+
}, [items])
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<MenuProvider value={controller}>
|
|
113
|
+
<CitricComponent
|
|
114
|
+
tag={tag}
|
|
115
|
+
component="menu"
|
|
116
|
+
className={listToClass([
|
|
117
|
+
className, appearance, roundedItems && 'rounded-items', showBorders && 'bordered', showShadows === false && 'no-shadow',
|
|
118
|
+
spaced && 'spaced', bgLevel && `bg-${bgLevel}`,
|
|
119
|
+
])}
|
|
120
|
+
onKeyDown={(e: any) => {
|
|
121
|
+
keyboardNavigation(e)
|
|
122
|
+
onKeyDown?.(e)
|
|
123
|
+
}}
|
|
124
|
+
{...props}
|
|
125
|
+
>
|
|
126
|
+
{header && <header>{header}</header>}
|
|
127
|
+
{current.parent && (
|
|
128
|
+
<div className="back-button">
|
|
129
|
+
<IconButton
|
|
130
|
+
icon="ArrowLeft"
|
|
131
|
+
aria-label={t.goBack}
|
|
132
|
+
onClick={(e) => {
|
|
133
|
+
e.stopPropagation()
|
|
134
|
+
const menu = e.target instanceof HTMLElement ? e.target.closest('[data-citric="menu"]') : undefined
|
|
135
|
+
if (current.parent) controller.setValue(current.parent)
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
const lastItem = Array.from(menu?.querySelectorAll('a, button') ?? []).find(el => el.textContent === current.label)
|
|
138
|
+
if (lastItem instanceof HTMLElement) lastItem.focus()
|
|
139
|
+
}, RENDER_DELAY)
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
<span>{current.label || t.goBack}</span>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
{sectioned ? <MenuItems items={current.items} /> : <nav><MenuItems items={current.items} /></nav>}
|
|
146
|
+
</CitricComponent>
|
|
147
|
+
</MenuProvider>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const dictionary = {
|
|
152
|
+
en: {
|
|
153
|
+
goBack: 'Go back',
|
|
154
|
+
},
|
|
155
|
+
pt: {
|
|
156
|
+
goBack: 'Voltar',
|
|
157
|
+
},
|
|
158
|
+
} satisfies Dictionary
|
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import { createContext, useContext, useEffect, useState } from 'react'
|
|
2
|
-
import { ValueController } from '../../utils/ValueController'
|
|
3
|
-
import { MenuState } from './types'
|
|
4
|
-
|
|
5
|
-
const context = createContext<ValueController<MenuState> | undefined>(undefined)
|
|
6
|
-
|
|
7
|
-
export const MenuProvider = context.Provider
|
|
8
|
-
|
|
9
|
-
export function useMenuController() {
|
|
10
|
-
return useContext(context)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function useMenuState(controller?: ValueController<MenuState>) {
|
|
14
|
-
const controllerFromContext = useMenuController()
|
|
15
|
-
controller ??= controllerFromContext
|
|
16
|
-
if (!controller) throw new Error('useMenuState used outside the context of a Menu')
|
|
17
|
-
const [state, setState] = useState(controller.getValue())
|
|
18
|
-
useEffect(() => controller.onChange(setState))
|
|
19
|
-
return state
|
|
20
|
-
}
|
|
1
|
+
import { createContext, useContext, useEffect, useState } from 'react'
|
|
2
|
+
import { ValueController } from '../../utils/ValueController'
|
|
3
|
+
import { MenuState } from './types'
|
|
4
|
+
|
|
5
|
+
const context = createContext<ValueController<MenuState> | undefined>(undefined)
|
|
6
|
+
|
|
7
|
+
export const MenuProvider = context.Provider
|
|
8
|
+
|
|
9
|
+
export function useMenuController() {
|
|
10
|
+
return useContext(context)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useMenuState(controller?: ValueController<MenuState>) {
|
|
14
|
+
const controllerFromContext = useMenuController()
|
|
15
|
+
controller ??= controllerFromContext
|
|
16
|
+
if (!controller) throw new Error('useMenuState used outside the context of a Menu')
|
|
17
|
+
const [state, setState] = useState(controller.getValue())
|
|
18
|
+
useEffect(() => controller.onChange(setState))
|
|
19
|
+
return state
|
|
20
|
+
}
|
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
|
|
2
|
-
import { useMemo } from 'react'
|
|
3
|
-
import { IconButton } from '../IconBox'
|
|
4
|
-
import { Overlay } from '../Overlay'
|
|
5
|
-
import { Menu } from './Menu'
|
|
6
|
-
import { MenuOverlayProps } from './types'
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Creates a menu overlay for its child element. Whenever the child element is clicked, the menu appears. Closing the menu is also handled
|
|
10
|
-
* by this component.
|
|
11
|
-
*
|
|
12
|
-
* The menu is defined by the property "items" and can contain sections and sub menus.
|
|
13
|
-
*
|
|
14
|
-
* If no child is provided, a button with the vertical ellipsis icon is rendered.
|
|
15
|
-
*
|
|
16
|
-
* @example
|
|
17
|
-
*
|
|
18
|
-
* ```
|
|
19
|
-
* const items: MenuItem[] = useMemo([
|
|
20
|
-
* { label: 'View', href: `/resource/${id}` },
|
|
21
|
-
* { label: 'Edit', onClick: () => editResource(id) },
|
|
22
|
-
* { label: 'Remove', onClick: () => removeResource(id), style: { color: theme.color.danger[500] } },
|
|
23
|
-
* ], [id])
|
|
24
|
-
*
|
|
25
|
-
* return <MenuOverlay items={items}><IconButton icon="Plus" /></MenuOverlay>
|
|
26
|
-
* ```
|
|
27
|
-
*/
|
|
28
|
-
export const MenuOverlay = ({
|
|
29
|
-
id, items, appearance, bgLevel, header, roundedItems, showBorders, showShadows, spaced, menuClass, menuStyle, children, ...props
|
|
30
|
-
}: MenuOverlayProps) => {
|
|
31
|
-
const t = useTranslate(dictionary)
|
|
32
|
-
const randomId = useMemo(() => `${Math.random()}`, [])
|
|
33
|
-
const menuProps = {
|
|
34
|
-
items, appearance, bgLevel, roundedItems, showBorders, showShadows, spaced, header, className: menuClass, style: menuStyle,
|
|
35
|
-
}
|
|
36
|
-
return (
|
|
37
|
-
<Overlay
|
|
38
|
-
content={<Menu {...menuProps} />}
|
|
39
|
-
attributes={{ id: id || randomId, style: { margin: '8px' } }}
|
|
40
|
-
triggerOn="click"
|
|
41
|
-
{...props}
|
|
42
|
-
>
|
|
43
|
-
{children ?? <IconButton icon="EllipsisVertical" aria-label={t.open} />}
|
|
44
|
-
</Overlay>
|
|
45
|
-
)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const dictionary = {
|
|
49
|
-
en: {
|
|
50
|
-
open: 'Open menu',
|
|
51
|
-
},
|
|
52
|
-
pt: {
|
|
53
|
-
open: 'Abrir menu',
|
|
54
|
-
},
|
|
55
|
-
} satisfies Dictionary
|
|
1
|
+
import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
|
|
2
|
+
import { useMemo } from 'react'
|
|
3
|
+
import { IconButton } from '../IconBox'
|
|
4
|
+
import { Overlay } from '../Overlay'
|
|
5
|
+
import { Menu } from './Menu'
|
|
6
|
+
import { MenuOverlayProps } from './types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a menu overlay for its child element. Whenever the child element is clicked, the menu appears. Closing the menu is also handled
|
|
10
|
+
* by this component.
|
|
11
|
+
*
|
|
12
|
+
* The menu is defined by the property "items" and can contain sections and sub menus.
|
|
13
|
+
*
|
|
14
|
+
* If no child is provided, a button with the vertical ellipsis icon is rendered.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
*
|
|
18
|
+
* ```
|
|
19
|
+
* const items: MenuItem[] = useMemo([
|
|
20
|
+
* { label: 'View', href: `/resource/${id}` },
|
|
21
|
+
* { label: 'Edit', onClick: () => editResource(id) },
|
|
22
|
+
* { label: 'Remove', onClick: () => removeResource(id), style: { color: theme.color.danger[500] } },
|
|
23
|
+
* ], [id])
|
|
24
|
+
*
|
|
25
|
+
* return <MenuOverlay items={items}><IconButton icon="Plus" /></MenuOverlay>
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export const MenuOverlay = ({
|
|
29
|
+
id, items, appearance, bgLevel, header, roundedItems, showBorders, showShadows, spaced, menuClass, menuStyle, children, ...props
|
|
30
|
+
}: MenuOverlayProps) => {
|
|
31
|
+
const t = useTranslate(dictionary)
|
|
32
|
+
const randomId = useMemo(() => `${Math.random()}`, [])
|
|
33
|
+
const menuProps = {
|
|
34
|
+
items, appearance, bgLevel, roundedItems, showBorders, showShadows, spaced, header, className: menuClass, style: menuStyle,
|
|
35
|
+
}
|
|
36
|
+
return (
|
|
37
|
+
<Overlay
|
|
38
|
+
content={<Menu {...menuProps} />}
|
|
39
|
+
attributes={{ id: id || randomId, style: { margin: '8px' } }}
|
|
40
|
+
triggerOn="click"
|
|
41
|
+
{...props}
|
|
42
|
+
>
|
|
43
|
+
{children ?? <IconButton icon="EllipsisVertical" aria-label={t.open} />}
|
|
44
|
+
</Overlay>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const dictionary = {
|
|
49
|
+
en: {
|
|
50
|
+
open: 'Open menu',
|
|
51
|
+
},
|
|
52
|
+
pt: {
|
|
53
|
+
open: 'Abrir menu',
|
|
54
|
+
},
|
|
55
|
+
} satisfies Dictionary
|
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
// TODO: scroll the element into view when it's focused. Very important for accessibility in scrollable menus.
|
|
2
|
-
|
|
3
|
-
function getCurrent(element: Element | undefined | null) {
|
|
4
|
-
return element instanceof HTMLElement
|
|
5
|
-
? element.querySelector('button:focus, a:focus') as HTMLElement | null
|
|
6
|
-
: null
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function getMenuItems(element: Element | undefined | null) {
|
|
10
|
-
return element instanceof HTMLElement ? element.querySelectorAll('button, a') : undefined
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function focusFirst(element: Element | undefined | null) {
|
|
14
|
-
const first = getMenuItems(element)?.[0]
|
|
15
|
-
if (first instanceof HTMLElement) first.focus()
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function focusLast(element: Element | undefined | null) {
|
|
19
|
-
const all = getMenuItems(element)
|
|
20
|
-
const last = all?.[all.length]
|
|
21
|
-
if (last instanceof HTMLElement) last.focus()
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function indexOf(elements: NodeListOf<Element>, element: Element | undefined | null) {
|
|
25
|
-
for (let i = 0; i < elements.length; i++) {
|
|
26
|
-
if (elements[i] === element) return i
|
|
27
|
-
}
|
|
28
|
-
return -1
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function focusPrevious(element: Element | undefined | null, current: HTMLElement) {
|
|
32
|
-
const all = getMenuItems(element)
|
|
33
|
-
const index = all ? indexOf(all, current) : -1
|
|
34
|
-
if (!all || index === -1) return
|
|
35
|
-
const prev = index === 0 ? all[all.length - 1] : all[index - 1]
|
|
36
|
-
if (prev instanceof HTMLElement) prev.focus()
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function focusNext(element: Element | undefined | null, current: HTMLElement) {
|
|
40
|
-
const all = getMenuItems(element)
|
|
41
|
-
const index = all ? indexOf(all, current) : -1
|
|
42
|
-
if (!all || index === -1) return
|
|
43
|
-
const next = index >= all.length - 1 ? all[0] : all[index + 1]
|
|
44
|
-
if (next instanceof HTMLElement) next.focus()
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function keyboardNavigation(event: React.KeyboardEvent) {
|
|
48
|
-
if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return
|
|
49
|
-
event.stopPropagation()
|
|
50
|
-
event.preventDefault()
|
|
51
|
-
const menu = event.target instanceof HTMLElement ? event.target.closest('[data-citric="menu"]') : undefined
|
|
52
|
-
const current = getCurrent(menu)
|
|
53
|
-
if (event.key === 'ArrowUp') {
|
|
54
|
-
if (current) focusPrevious(menu, current)
|
|
55
|
-
else focusLast(menu)
|
|
56
|
-
} else {
|
|
57
|
-
if (current) focusNext(menu, current)
|
|
58
|
-
else focusFirst(menu)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
1
|
+
// TODO: scroll the element into view when it's focused. Very important for accessibility in scrollable menus.
|
|
2
|
+
|
|
3
|
+
function getCurrent(element: Element | undefined | null) {
|
|
4
|
+
return element instanceof HTMLElement
|
|
5
|
+
? element.querySelector('button:focus, a:focus') as HTMLElement | null
|
|
6
|
+
: null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getMenuItems(element: Element | undefined | null) {
|
|
10
|
+
return element instanceof HTMLElement ? element.querySelectorAll('button, a') : undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function focusFirst(element: Element | undefined | null) {
|
|
14
|
+
const first = getMenuItems(element)?.[0]
|
|
15
|
+
if (first instanceof HTMLElement) first.focus()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function focusLast(element: Element | undefined | null) {
|
|
19
|
+
const all = getMenuItems(element)
|
|
20
|
+
const last = all?.[all.length]
|
|
21
|
+
if (last instanceof HTMLElement) last.focus()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function indexOf(elements: NodeListOf<Element>, element: Element | undefined | null) {
|
|
25
|
+
for (let i = 0; i < elements.length; i++) {
|
|
26
|
+
if (elements[i] === element) return i
|
|
27
|
+
}
|
|
28
|
+
return -1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function focusPrevious(element: Element | undefined | null, current: HTMLElement) {
|
|
32
|
+
const all = getMenuItems(element)
|
|
33
|
+
const index = all ? indexOf(all, current) : -1
|
|
34
|
+
if (!all || index === -1) return
|
|
35
|
+
const prev = index === 0 ? all[all.length - 1] : all[index - 1]
|
|
36
|
+
if (prev instanceof HTMLElement) prev.focus()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function focusNext(element: Element | undefined | null, current: HTMLElement) {
|
|
40
|
+
const all = getMenuItems(element)
|
|
41
|
+
const index = all ? indexOf(all, current) : -1
|
|
42
|
+
if (!all || index === -1) return
|
|
43
|
+
const next = index >= all.length - 1 ? all[0] : all[index + 1]
|
|
44
|
+
if (next instanceof HTMLElement) next.focus()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function keyboardNavigation(event: React.KeyboardEvent) {
|
|
48
|
+
if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return
|
|
49
|
+
event.stopPropagation()
|
|
50
|
+
event.preventDefault()
|
|
51
|
+
const menu = event.target instanceof HTMLElement ? event.target.closest('[data-citric="menu"]') : undefined
|
|
52
|
+
const current = getCurrent(menu)
|
|
53
|
+
if (event.key === 'ArrowUp') {
|
|
54
|
+
if (current) focusPrevious(menu, current)
|
|
55
|
+
else focusLast(menu)
|
|
56
|
+
} else {
|
|
57
|
+
if (current) focusNext(menu, current)
|
|
58
|
+
else focusFirst(menu)
|
|
59
|
+
}
|
|
60
|
+
}
|