├── libs ├── ui │ ├── theme │ │ ├── index.ts │ │ ├── register.ts │ │ └── styles │ │ │ ├── global.css │ │ │ ├── variables.css │ │ │ └── global-standalone.css │ ├── hooks │ │ ├── index.ts │ │ ├── focus-trap │ │ │ ├── index.ts │ │ │ └── useFocusReturnToLast.ts │ │ ├── keyboard │ │ │ ├── index.ts │ │ │ ├── useHotkey.ts │ │ │ ├── types.ts │ │ │ └── lib.ts │ │ └── core │ │ │ ├── useUniversalLayoutEffect.ts │ │ │ ├── useVariableRef.ts │ │ │ ├── use-id.ts │ │ │ ├── useMergedCallback.ts │ │ │ ├── useDidUpdateEffect.ts │ │ │ ├── index.ts │ │ │ ├── useEventCallback.ts │ │ │ ├── useForkCallback.ts │ │ │ └── useForkRef.ts │ ├── lib │ │ ├── theme.ts │ │ ├── dom │ │ │ ├── index.ts │ │ │ ├── shared.ts │ │ │ ├── style.ts │ │ │ ├── events.ts │ │ │ ├── overflow.ts │ │ │ └── tabbable.ts │ │ └── refs.ts │ ├── types │ │ ├── index.ts │ │ ├── global.d.ts │ │ ├── common.ts │ │ ├── global.overrides.d.ts │ │ ├── theme.ts │ │ └── elements.ts │ ├── core │ │ ├── button │ │ │ ├── index.ts │ │ │ ├── button.stories.tsx │ │ │ └── button.tsx │ │ ├── transition │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ ├── constants.ts │ │ │ ├── transition.tsx │ │ │ ├── useTransition.ts │ │ │ ├── transition.stories.tsx │ │ │ ├── Collapse.tsx │ │ │ └── transition.module.css │ │ ├── svg-icon │ │ │ ├── index.ts │ │ │ └── svg-icon.tsx │ │ ├── typography │ │ │ ├── index.ts │ │ │ ├── typography.stories.tsx │ │ │ └── typography.tsx │ │ ├── icon-button │ │ │ ├── index.ts │ │ │ └── icon-button.tsx │ │ ├── popover │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ ├── popover.stories.tsx │ │ │ ├── usePopover.ts │ │ │ ├── lib.ts │ │ │ └── popover.tsx │ │ ├── card │ │ │ ├── index.ts │ │ │ ├── card.tsx │ │ │ └── paper.tsx │ │ ├── grid │ │ │ ├── index.ts │ │ │ ├── grid.tsx │ │ │ ├── grid.module.css │ │ │ └── spaced.tsx │ │ ├── modal │ │ │ ├── index.ts │ │ │ ├── backdrop.tsx │ │ │ ├── portal.tsx │ │ │ ├── modal-manager.ts │ │ │ ├── trap-focus.tsx │ │ │ └── modal.tsx │ │ ├── input │ │ │ ├── index.ts │ │ │ ├── input.tsx │ │ │ ├── types.ts │ │ │ ├── html.tsx │ │ │ └── input-box.tsx │ │ └── dialog │ │ │ ├── footer.tsx │ │ │ ├── content.tsx │ │ │ ├── index.ts │ │ │ ├── header.tsx │ │ │ ├── dialog.stories.tsx │ │ │ └── dialog.tsx │ ├── static │ │ └── icons │ │ │ ├── ArrowDropDown.svg │ │ │ ├── ArrowDropUp.svg │ │ │ ├── Add.svg │ │ │ ├── Warn.svg │ │ │ ├── Error.svg │ │ │ ├── Info.svg │ │ │ ├── KeyboardArrowDown.svg │ │ │ ├── KeyboardArrowLeft.svg │ │ │ ├── KeyboardArrowRight.svg │ │ │ ├── Done.svg │ │ │ ├── CalendarToday.svg │ │ │ ├── Visibility.svg │ │ │ ├── Help.svg │ │ │ ├── VisibilityOff.svg │ │ │ └── Notifications.svg │ ├── README.md │ ├── postcss.config.js │ ├── icons │ │ ├── ArrowDropUp.tsx │ │ ├── Add.tsx │ │ ├── ArrowDropDown.tsx │ │ ├── Warn.tsx │ │ ├── Done.tsx │ │ ├── KeyboardArrowDown.tsx │ │ ├── Error.tsx │ │ ├── KeyboardArrowLeft.tsx │ │ ├── KeyboardArrowRight.tsx │ │ ├── Info.tsx │ │ ├── CalendarToday.tsx │ │ ├── Visibility.tsx │ │ ├── Notifications.tsx │ │ ├── Help.tsx │ │ ├── VisibilityOff.tsx │ │ └── index.ts │ ├── .storybook │ │ ├── preview.js │ │ └── main.js │ ├── tsconfig.json │ ├── index.js │ ├── stories │ │ └── Icons │ │ │ ├── Icons.stories.mdx │ │ │ ├── IconPreviewButton.tsx │ │ │ └── icons-preview.tsx │ ├── .svgrrc.js │ ├── package.json │ └── tailwind.config.js ├── hooks │ ├── readme.md │ ├── index.ts │ ├── package.json │ └── common │ │ └── useRequiredContext.ts ├── utils │ ├── readme.md │ ├── core │ │ ├── object │ │ │ ├── assoc.ts │ │ │ └── create.ts │ │ ├── array │ │ │ ├── exclude.ts │ │ │ ├── difference.ts │ │ │ └── difference.test.ts │ │ ├── is-empty.ts │ │ ├── index.ts │ │ ├── function │ │ │ ├── core.ts │ │ │ ├── debounce.test.ts │ │ │ └── debounce.ts │ │ └── core.ts │ ├── package.json │ └── tsconfig.json └── config │ ├── readme.md │ ├── ts │ ├── node.json │ └── base.json │ ├── jest │ ├── ts-paths.js │ ├── index.js │ └── base.js │ ├── package.json │ └── next │ └── index.js ├── apps ├── client-web │ ├── .env │ ├── .gitignore │ ├── postcss.config.js │ ├── tailwind.config.js │ ├── pages │ │ ├── 404.tsx │ │ ├── _app.tsx │ │ ├── _error.tsx │ │ └── index.tsx │ ├── tsconfig.node.json │ ├── next-env.d.ts │ ├── tsconfig.json │ ├── next.config.js │ ├── README.md │ ├── .babelrc │ └── package.json └── todo-web │ ├── .env │ ├── .gitignore │ ├── shared │ └── types │ │ ├── index.ts │ │ └── task.ts │ ├── features │ ├── add-task │ │ ├── index.ts │ │ └── ui │ │ │ ├── index.ts │ │ │ └── Input.tsx │ ├── remove-task │ │ ├── index.ts │ │ └── ui.tsx │ └── toggle-task │ │ ├── index.ts │ │ └── ui.tsx │ ├── readme.md │ ├── entities │ └── task │ │ ├── ui │ │ ├── index.ts │ │ └── list-item.tsx │ │ ├── index.ts │ │ └── model.ts │ ├── pages │ ├── todos.tsx │ ├── 404.tsx │ ├── _app.tsx │ ├── _error.tsx │ └── index.tsx │ ├── postcss.config.js │ ├── tailwind.config.js │ ├── next-env.d.ts │ ├── tsconfig.json │ ├── next.config.js │ ├── package.json │ └── screens │ └── todos │ └── index.tsx ├── .prettierignore ├── .husky ├── pre-push ├── pre-commit └── commit-msg ├── .yarn ├── install-state.gz └── build-state.yml ├── _templates ├── ws │ └── new │ │ ├── readme.ejs.t │ │ ├── package.ejs.t │ │ └── prompt.js └── ui │ ├── new │ ├── prompt.js │ ├── index.ejs.t │ └── component.ejs.t │ └── add-component │ ├── add-export.ejs.t │ ├── add-type-export.ejs.t │ ├── prompt.js │ └── component.ejs.t ├── .eslintignore ├── tsconfig.node.json ├── .lintstagedrc ├── .editorconfig ├── .prettierrc ├── .yarnrc.yml ├── .dockerignore ├── jest.config.js ├── .gitignore ├── tsconfig.base.json ├── Taskfile.yaml ├── tsconfig.json ├── .github └── workflows │ └── pull-request.yaml ├── commitlint.config.js ├── docker-compose.yml ├── package.json ├── README.md ├── .eslintrc.js ├── Dockerfile └── .gitattributes /libs/ui/theme/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/client-web/.env: -------------------------------------------------------------------------------- 1 | ANALYZE=false 2 | -------------------------------------------------------------------------------- /apps/client-web/.gitignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | -------------------------------------------------------------------------------- /apps/todo-web/.env: -------------------------------------------------------------------------------- 1 | ANALYZE=false 2 | -------------------------------------------------------------------------------- /apps/todo-web/.gitignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | -------------------------------------------------------------------------------- /libs/ui/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './core'; 2 | -------------------------------------------------------------------------------- /apps/todo-web/shared/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './task'; 2 | -------------------------------------------------------------------------------- /apps/todo-web/features/add-task/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ui'; 2 | -------------------------------------------------------------------------------- /libs/hooks/readme.md: -------------------------------------------------------------------------------- 1 | # @libs/hooks 2 | 3 | TODO Add description 4 | -------------------------------------------------------------------------------- /libs/utils/readme.md: -------------------------------------------------------------------------------- 1 | # @libs/utils 2 | 3 | TODO Add description 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .pnp.js 2 | .yarn/ 3 | node_modules/ 4 | **/.next/ 5 | -------------------------------------------------------------------------------- /apps/todo-web/features/remove-task/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ui'; 2 | -------------------------------------------------------------------------------- /apps/todo-web/features/toggle-task/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ui'; 2 | -------------------------------------------------------------------------------- /libs/config/readme.md: -------------------------------------------------------------------------------- 1 | # @libs/configs 2 | 3 | TODO Add description 4 | -------------------------------------------------------------------------------- /apps/todo-web/readme.md: -------------------------------------------------------------------------------- 1 | # @apps/todo-web 2 | 3 | TODO Add description 4 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | task check 5 | -------------------------------------------------------------------------------- /apps/todo-web/entities/task/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { TaskListItem } from './list-item'; 2 | -------------------------------------------------------------------------------- /apps/todo-web/features/add-task/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { AddTaskInput } from './Input'; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /libs/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useRequiredContext } from './common/useRequiredContext'; 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /libs/ui/hooks/focus-trap/index.ts: -------------------------------------------------------------------------------- 1 | export { useFocusReturnToLast } from './useFocusReturnToLast'; 2 | -------------------------------------------------------------------------------- /libs/ui/lib/theme.ts: -------------------------------------------------------------------------------- 1 | export const getColorVariable = (color: string) => `var(--palette-${color})`; 2 | -------------------------------------------------------------------------------- /.yarn/install-state.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/secundant/ts-monorepo-template/HEAD/.yarn/install-state.gz -------------------------------------------------------------------------------- /libs/ui/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | export * from './elements'; 3 | export * from './theme'; 4 | -------------------------------------------------------------------------------- /apps/todo-web/pages/todos.tsx: -------------------------------------------------------------------------------- 1 | import { TodosScreen } from '@/screens/todos'; 2 | 3 | export default TodosScreen; 4 | -------------------------------------------------------------------------------- /libs/ui/core/button/index.ts: -------------------------------------------------------------------------------- 1 | export { Button } from './button'; 2 | 3 | export type { ButtonProps } from './button'; 4 | -------------------------------------------------------------------------------- /libs/ui/core/transition/index.ts: -------------------------------------------------------------------------------- 1 | export { Transition } from './transition'; 2 | export { useTransition } from './useTransition'; 3 | -------------------------------------------------------------------------------- /apps/todo-web/entities/task/index.ts: -------------------------------------------------------------------------------- 1 | import * as TaskModel from './model'; 2 | 3 | export * from './ui'; 4 | export { TaskModel }; 5 | -------------------------------------------------------------------------------- /_templates/ws/new/readme.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: <%=type%>/<%=name%>/readme.md 3 | --- 4 | # @<%=type%>/<%=name%> 5 | 6 | TODO Add description 7 | -------------------------------------------------------------------------------- /libs/ui/core/svg-icon/index.ts: -------------------------------------------------------------------------------- 1 | export { SvgIcon, createSvgIcon } from './svg-icon'; 2 | 3 | export type { SvgIconProps } from './svg-icon'; 4 | -------------------------------------------------------------------------------- /apps/client-web/postcss.config.js: -------------------------------------------------------------------------------- 1 | const { getPostCSSConfig } = require('@libs/ui'); 2 | 3 | module.exports = getPostCSSConfig({ cwd: __dirname }); 4 | -------------------------------------------------------------------------------- /apps/todo-web/postcss.config.js: -------------------------------------------------------------------------------- 1 | const { getPostCSSConfig } = require('@libs/ui'); 2 | 3 | module.exports = getPostCSSConfig({ cwd: __dirname }); 4 | -------------------------------------------------------------------------------- /apps/todo-web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { getTailwindConfig } = require('@libs/ui'); 2 | 3 | module.exports = getTailwindConfig({ cwd: __dirname }); 4 | -------------------------------------------------------------------------------- /_templates/ui/new/prompt.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | type: 'input', 4 | name: 'name', 5 | message: 'Name of component' 6 | } 7 | ]; 8 | -------------------------------------------------------------------------------- /apps/client-web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { getTailwindConfig } = require('@libs/ui'); 2 | 3 | module.exports = getTailwindConfig({ cwd: __dirname }); 4 | -------------------------------------------------------------------------------- /libs/ui/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.css' { 2 | const classes: { readonly [key: string]: string }; 3 | export default classes; 4 | } 5 | -------------------------------------------------------------------------------- /libs/ui/core/typography/index.ts: -------------------------------------------------------------------------------- 1 | // Components 2 | export { Typography } from './typography'; 3 | 4 | // Types 5 | export type { TypographyProps } from './typography'; 6 | -------------------------------------------------------------------------------- /apps/todo-web/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | export default function Error404(): ReactElement { 4 | return

Not found :/

; 5 | } 6 | -------------------------------------------------------------------------------- /libs/ui/core/icon-button/index.ts: -------------------------------------------------------------------------------- 1 | // Components 2 | export { IconButton } from './icon-button'; 3 | 4 | // Types 5 | export type { IconButtonProps } from './icon-button'; 6 | -------------------------------------------------------------------------------- /libs/ui/lib/dom/index.ts: -------------------------------------------------------------------------------- 1 | export * from './shared'; 2 | export * from './overflow'; 3 | export * from './tabbable'; 4 | export * from './style'; 5 | export * from './events'; 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | **/_ignore-*/**/* 3 | *.local.{js,ts,tsx} 4 | dist/**/* 5 | es6/**/* 6 | es2015/**/* 7 | esnext/**/* 8 | .eslintcache 9 | .pnp.сjs 10 | -------------------------------------------------------------------------------- /apps/client-web/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | export default function Error404(): ReactElement { 4 | return

Not found :/

; 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "es2018", 6 | "jsx": "react" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /libs/config/ts/node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "es2018", 6 | "jsx": "react" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /libs/ui/hooks/keyboard/index.ts: -------------------------------------------------------------------------------- 1 | export { useGlobalHotkey } from './useHotkey'; 2 | export { getHotkeyHandler } from './lib'; 3 | 4 | export type { KeyboardHotKey } from './types'; 5 | -------------------------------------------------------------------------------- /libs/utils/core/object/assoc.ts: -------------------------------------------------------------------------------- 1 | export const assoc = (key: K, value: T[K], target: T): T => { 2 | target[key] = value; 3 | return target; 4 | }; 5 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,tsx}": [ 3 | "prettier --write", 4 | "eslint --cache --fix" 5 | ], 6 | "*.{json,js,graphql}": [ 7 | "prettier --write" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /apps/client-web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "es2018", 6 | "jsx": "react" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /_templates/ws/new/package.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: <%=type%>/<%=name%>/package.json 3 | --- 4 | { 5 | "name": "@<%=type%>/<%=name%>", 6 | "version": "0.0.1", 7 | "packageManager": "yarn@3.2.0" 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /libs/ui/core/popover/index.ts: -------------------------------------------------------------------------------- 1 | // Components 2 | export { Popover } from './popover'; 3 | export { usePopover } from './usePopover'; 4 | 5 | // Types 6 | export type { PopoverProps } from './popover'; 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "printWidth": 100, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "semi": true, 7 | "singleQuote": true, 8 | "trailingComma": "none" 9 | } 10 | -------------------------------------------------------------------------------- /libs/ui/hooks/core/useUniversalLayoutEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect } from 'react'; 2 | 3 | export const useUniversalLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; 4 | -------------------------------------------------------------------------------- /libs/ui/static/icons/ArrowDropDown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /libs/ui/static/icons/ArrowDropUp.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_templates/ui/add-component/add-export.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: libs/ui/core/<%=groupName%>/index.ts 3 | inject: true 4 | after: // Components 5 | --- 6 | export { <%=h.changeCase.pascalCase(name)%> } from './<%=name%>'; 7 | -------------------------------------------------------------------------------- /libs/utils/core/array/exclude.ts: -------------------------------------------------------------------------------- 1 | import { includesIn, negate } from '../function/core'; 2 | 3 | export const exclude = (target: T[], excluded: T[]): T[] => 4 | target.filter(negate(includesIn(excluded))); 5 | -------------------------------------------------------------------------------- /libs/utils/core/is-empty.ts: -------------------------------------------------------------------------------- 1 | // TODO Add support for every variable 2 | export function isEmpty(value: unknown) { 3 | if (Array.isArray(value)) { 4 | return value.length === 0; 5 | } 6 | return !!value; 7 | } 8 | -------------------------------------------------------------------------------- /_templates/ui/add-component/add-type-export.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: libs/ui/core/<%=groupName%>/index.ts 3 | inject: true 4 | after: // Types 5 | --- 6 | export type { <%=h.changeCase.pascalCase(name)%>Props } from './<%=name%>'; 7 | -------------------------------------------------------------------------------- /libs/ui/README.md: -------------------------------------------------------------------------------- 1 | # @libs/ui 2 | 3 | ## Usage 4 | 5 | 1. Add `@libs/ui` as dependency 6 | 2. Install required dependencies - `yarn add -D tailwind` 7 | 3. In `_app` file import global styles `import '@libs/ui/theme/register'` 8 | -------------------------------------------------------------------------------- /libs/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@libs/utils", 3 | "version": "0.0.1", 4 | "packageManager": "yarn@3.2.0", 5 | "devDependencies": { 6 | "@libs/config": "*", 7 | "@types/jest": "^27.4.1" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /libs/utils/core/object/create.ts: -------------------------------------------------------------------------------- 1 | export const objectFromArray = ( 2 | list: T[], 3 | fn: (value: T) => K 4 | ): Record => Object.fromEntries(list.map(value => [fn(value), value])) as Record; 5 | -------------------------------------------------------------------------------- /apps/todo-web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /libs/ui/core/card/index.ts: -------------------------------------------------------------------------------- 1 | // Components 2 | export { Paper } from './paper'; 3 | 4 | export { Card } from './card'; 5 | 6 | // Types 7 | export type { PaperProps } from './paper'; 8 | 9 | export type { CardProps } from './card'; 10 | -------------------------------------------------------------------------------- /apps/client-web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /libs/ui/core/grid/index.ts: -------------------------------------------------------------------------------- 1 | // Components 2 | export { Spaced } from './spaced'; 3 | 4 | export { Grid } from './grid'; 5 | 6 | // Types 7 | export type { SpacedProps } from './spaced'; 8 | 9 | export type { GridProps } from './grid'; 10 | -------------------------------------------------------------------------------- /libs/ui/hooks/core/useVariableRef.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useRef } from 'react'; 2 | 3 | export function useVariableRef(variable: T): RefObject { 4 | const ref = useRef(variable); 5 | 6 | ref.current = variable; 7 | return ref; 8 | } 9 | -------------------------------------------------------------------------------- /libs/ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | 3 | module.exports = { 4 | plugins: { 5 | tailwindcss: { 6 | config: join(__dirname, 'tailwind.config.js') 7 | }, 8 | autoprefixer: {} 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /libs/ui/static/icons/Add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /libs/ui/hooks/core/use-id.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | export function useId(id?: string) { 4 | return useMemo(() => id ?? randomId(), [id]); 5 | } 6 | 7 | export const randomId = () => `id-${Math.random().toString(36).slice(2, 11)}`; 8 | -------------------------------------------------------------------------------- /libs/ui/icons/ArrowDropUp.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgArrowDropUp', 4 | '0 0 24 24', 5 | , 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /apps/todo-web/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@libs/ui/theme/register'; 2 | import { AppProps } from 'next/app'; 3 | import React from 'react'; 4 | 5 | export default function App({ Component, pageProps }: AppProps) { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /libs/ui/icons/Add.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgAdd', 4 | '0 0 24 24', 5 | , 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /libs/ui/icons/ArrowDropDown.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgArrowDropDown', 4 | '0 0 24 24', 5 | , 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /apps/client-web/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@libs/ui/theme/register'; 2 | import { AppProps } from 'next/app'; 3 | import React from 'react'; 4 | 5 | export default function App({ Component, pageProps }: AppProps) { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /apps/todo-web/pages/_error.tsx: -------------------------------------------------------------------------------- 1 | import NextErrorPage, { ErrorProps } from 'next/error'; 2 | import React, { ReactElement } from 'react'; 3 | 4 | export default function MyErrorPage(props: ErrorProps): ReactElement { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /libs/ui/static/icons/Warn.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_templates/ui/new/index.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: libs/ui/core/<%=name%>/index.ts 3 | --- 4 | // Components 5 | export { <%=h.changeCase.pascalCase(name)%> } from './<%=name%>'; 6 | 7 | // Types 8 | export type { <%=h.changeCase.pascalCase(name)%>Props } from './<%=name%>'; 9 | -------------------------------------------------------------------------------- /apps/client-web/pages/_error.tsx: -------------------------------------------------------------------------------- 1 | import NextErrorPage, { ErrorProps } from 'next/error'; 2 | import React, { ReactElement } from 'react'; 3 | 4 | export default function MyErrorPage(props: ErrorProps): ReactElement { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /libs/hooks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@libs/hooks", 3 | "version": "0.0.1", 4 | "packageManager": "yarn@3.2.0", 5 | "dependencies": { 6 | "react": "^17.0.2" 7 | }, 8 | "devDependencies": { 9 | "@types/react": "^17.0.43" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /libs/ui/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import '../theme/register'; 2 | 3 | export const parameters = { 4 | actions: { argTypesRegex: '^on[A-Z].*' }, 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/ 9 | } 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /_templates/ui/add-component/prompt.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | type: 'input', 4 | name: 'groupName', 5 | message: 'Name of exists components folder' 6 | }, 7 | { 8 | type: 'input', 9 | name: 'name', 10 | message: 'Name of new component' 11 | } 12 | ]; 13 | -------------------------------------------------------------------------------- /libs/ui/theme/register.ts: -------------------------------------------------------------------------------- 1 | import './styles/global.css'; 2 | import './styles/variables.css'; 3 | 4 | import '@fontsource/inter/200.css'; 5 | import '@fontsource/inter/300.css'; 6 | import '@fontsource/inter/400.css'; 7 | import '@fontsource/inter/500.css'; 8 | import '@fontsource/inter/700.css'; 9 | -------------------------------------------------------------------------------- /libs/ui/icons/Warn.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgWarn', 4 | '0 0 24 24', 5 | , 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /_templates/ws/new/prompt.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | type: 'select', 4 | name: 'type', 5 | message: 'Select scope of new package', 6 | choices: ['apps', 'libs'] 7 | }, 8 | { 9 | type: 'input', 10 | name: 'name', 11 | message: 'Name of package' 12 | } 13 | ]; 14 | -------------------------------------------------------------------------------- /libs/ui/lib/dom/shared.ts: -------------------------------------------------------------------------------- 1 | export const getOwnerWindow = (node?: Node | null) => getDocumentWindow(getOwnerDocument(node)); 2 | export const getOwnerDocument = (node?: Node | null) => node?.ownerDocument ?? document; 3 | export const getDocumentWindow = (document: Document) => document.defaultView || window; 4 | -------------------------------------------------------------------------------- /libs/ui/static/icons/Error.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /libs/ui/static/icons/Info.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /libs/utils/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './core'; 2 | export * from './function/core'; 3 | 4 | export { debounce } from './function/debounce'; 5 | export { assoc } from './object/assoc'; 6 | export { objectFromArray } from './object/create'; 7 | export { exclude } from './array/exclude'; 8 | export { isEmpty } from './is-empty'; 9 | -------------------------------------------------------------------------------- /libs/ui/icons/Done.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgDone', 4 | '0 0 24 24', 5 | , 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /libs/ui/icons/KeyboardArrowDown.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgKeyboardArrowDown', 4 | '0 0 24 24', 5 | 6 | ); 7 | -------------------------------------------------------------------------------- /libs/ui/icons/Error.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgError', 4 | '0 0 24 24', 5 | 6 | ); 7 | -------------------------------------------------------------------------------- /libs/ui/icons/KeyboardArrowLeft.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgKeyboardArrowLeft', 4 | '0 0 24 24', 5 | 6 | ); 7 | -------------------------------------------------------------------------------- /libs/ui/icons/KeyboardArrowRight.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgKeyboardArrowRight', 4 | '0 0 24 24', 5 | 6 | ); 7 | -------------------------------------------------------------------------------- /apps/todo-web/shared/types/task.ts: -------------------------------------------------------------------------------- 1 | export type TaskID = string; 2 | 3 | export interface Task { 4 | id: TaskID; 5 | label: string; 6 | completed: boolean; 7 | } 8 | 9 | export interface TaskCreationParams { 10 | label: string; 11 | } 12 | 13 | export interface TaskUpdateParams extends Pick, Partial> {} 14 | -------------------------------------------------------------------------------- /libs/ui/static/icons/KeyboardArrowDown.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /libs/ui/static/icons/KeyboardArrowLeft.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /libs/ui/static/icons/KeyboardArrowRight.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /libs/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@libs/config/ts/base.json", 3 | "compilerOptions": { 4 | "types": ["jest"], 5 | "baseUrl": ".", 6 | "rootDir": ".", 7 | "allowJs": true, 8 | "noEmit": true, 9 | "incremental": true 10 | }, 11 | "include": ["**/*.ts", "**/*.tsx"], 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /libs/ui/core/card/card.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { ReactNode } from 'react'; 3 | 4 | export interface CardProps { 5 | className?: string; 6 | children: NonNullable; 7 | } 8 | 9 | export function Card({ children, className }: CardProps) { 10 | return
{children}
; 11 | } 12 | -------------------------------------------------------------------------------- /libs/ui/core/grid/grid.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { ReactNode } from 'react'; 3 | 4 | export interface GridProps { 5 | className?: string; 6 | children: NonNullable; 7 | } 8 | 9 | export function Grid({ children, className }: GridProps) { 10 | return
{children}
; 11 | } 12 | -------------------------------------------------------------------------------- /libs/ui/core/modal/index.ts: -------------------------------------------------------------------------------- 1 | // Components 2 | export { Backdrop } from './backdrop'; 3 | 4 | export { Portal } from './portal'; 5 | 6 | export { Modal } from './modal'; 7 | 8 | // Types 9 | export type { BackdropProps } from './backdrop'; 10 | 11 | export type { PortalProps } from './portal'; 12 | 13 | export type { ModalProps } from './modal'; 14 | -------------------------------------------------------------------------------- /libs/ui/core/input/index.ts: -------------------------------------------------------------------------------- 1 | // Components 2 | export { InputBox } from './input-box'; 3 | 4 | export { HtmlInput } from './html'; 5 | 6 | export { Input } from './input'; 7 | 8 | // Types 9 | export type { InputBoxProps } from './input-box'; 10 | 11 | export type { HtmlInputProps } from './html'; 12 | 13 | export type { InputProps } from './input'; 14 | -------------------------------------------------------------------------------- /libs/ui/core/grid/grid.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | Spaced 3 | */ 4 | 5 | .Spaced { 6 | @apply flex items-center; 7 | --spaced-size: 0; 8 | --spaced-size-reversed: calc(var(--spaced-size) * -1); 9 | } 10 | 11 | .Spaced:not(:empty) { 12 | margin: var(--spaced-size-reversed); 13 | } 14 | 15 | .Spaced > * { 16 | margin: var(--spaced-size) !important; 17 | } 18 | -------------------------------------------------------------------------------- /libs/ui/icons/Info.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgInfo', 4 | '0 0 24 24', 5 | , 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /libs/ui/static/icons/Done.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /libs/hooks/common/useRequiredContext.ts: -------------------------------------------------------------------------------- 1 | import { Context, useContext } from 'react'; 2 | 3 | export function useRequiredContext(context: Context): T { 4 | const contextValue = useContext(context); 5 | 6 | if (!contextValue) { 7 | throw new Error(`Context "${context.displayName ?? 'unnamed'}" is not defined`); 8 | } 9 | return contextValue; 10 | } 11 | -------------------------------------------------------------------------------- /libs/ui/core/dialog/footer.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { ReactNode } from 'react'; 3 | 4 | export interface DialogFooterProps { 5 | className?: string; 6 | children: NonNullable; 7 | } 8 | 9 | export function DialogFooter({ children, className }: DialogFooterProps) { 10 | return
{children}
; 11 | } 12 | -------------------------------------------------------------------------------- /libs/ui/hooks/core/useMergedCallback.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | export function useMergedCallback( 4 | ...fns: Array<((...args: Args) => any) | null | undefined> 5 | ): (...args: Args) => void { 6 | return useCallback((...args: Args) => { 7 | for (const fn of fns) { 8 | if (fn) fn(...args); 9 | } 10 | }, fns); 11 | } 12 | -------------------------------------------------------------------------------- /libs/ui/core/dialog/content.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { ReactNode } from 'react'; 3 | 4 | export interface DialogContentProps { 5 | className?: string; 6 | children: NonNullable; 7 | } 8 | 9 | export function DialogContent({ children, className }: DialogContentProps) { 10 | return
{children}
; 11 | } 12 | -------------------------------------------------------------------------------- /libs/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@libs/config/ts/base.json", 3 | "compilerOptions": { 4 | "types": ["jest"], 5 | "baseUrl": ".", 6 | "rootDir": ".", 7 | "allowJs": true, 8 | "noEmit": true, 9 | "incremental": true 10 | }, 11 | "include": ["types/global.d.ts", "types/global.overrides.d.ts", "**/*.ts", "**/*.tsx"], 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /libs/ui/hooks/core/useDidUpdateEffect.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, EffectCallback, useEffect, useRef } from 'react'; 2 | 3 | export function useDidUpdateEffect(fn: EffectCallback, deps?: DependencyList) { 4 | const mounted = useRef(false); 5 | 6 | useEffect(() => { 7 | if (mounted.current) { 8 | return fn(); 9 | } else { 10 | mounted.current = true; 11 | } 12 | }, deps); 13 | } 14 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs 7 | spec: "@yarnpkg/plugin-workspace-tools" 8 | - path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs 9 | spec: "@yarnpkg/plugin-typescript" 10 | 11 | yarnPath: .yarn/releases/yarn-3.2.0.cjs 12 | -------------------------------------------------------------------------------- /libs/ui/core/transition/types.ts: -------------------------------------------------------------------------------- 1 | export type TransitionStatus = 'enter' | 'entered' | 'exit' | 'exited' | 'entering' | 'exiting'; 2 | export type TransitionPhaseHandlerName = `on${Capitalize}`; 3 | 4 | export type TransitionPhaseHandlers = Partial void>>; 5 | 6 | export interface TransitionOptions extends TransitionPhaseHandlers { 7 | open?: boolean; 8 | duration?: number; 9 | exitDuration?: number; 10 | } 11 | -------------------------------------------------------------------------------- /apps/todo-web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@libs/config/ts/base.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "rootDir": ".", 6 | "lib": ["dom", "dom.iterable", "esnext"], 7 | "types": ["next", "node"], 8 | "paths": { 9 | "@/*": ["./*"] 10 | }, 11 | "incremental": true, 12 | "allowJs": true, 13 | "noEmit": true 14 | }, 15 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 16 | "exclude": ["node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /libs/utils/core/function/core.ts: -------------------------------------------------------------------------------- 1 | export const includes = 2 | (value: T) => 3 | (list: T[]) => 4 | list.includes(value); 5 | 6 | export const includesIn = 7 | (list: T[]) => 8 | (value: T) => 9 | list.includes(value); 10 | 11 | export const negate = 12 | (fn: (...args: Args) => boolean) => 13 | (...args: Args) => 14 | !fn(...args); 15 | 16 | export const identity = (value: T) => value; 17 | export const noop = () => void 0; 18 | -------------------------------------------------------------------------------- /apps/client-web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@libs/config/ts/base.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "rootDir": ".", 6 | "lib": ["dom", "dom.iterable", "esnext"], 7 | "types": ["next", "node"], 8 | "paths": { 9 | "@/*": ["./*"] 10 | }, 11 | "incremental": true, 12 | "allowJs": true, 13 | "noEmit": true 14 | }, 15 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 16 | "exclude": ["node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /libs/ui/hooks/core/index.ts: -------------------------------------------------------------------------------- 1 | export { useForkRef, useChildrenForkRef } from './useForkRef'; 2 | export { useUniversalLayoutEffect } from './useUniversalLayoutEffect'; 3 | export { useDidUpdateEffect } from './useDidUpdateEffect'; 4 | export { useMergedCallback } from './useMergedCallback'; 5 | export { useEventCallback } from './useEventCallback'; 6 | export { useForkCallback } from './useForkCallback'; 7 | export { useVariableRef } from './useVariableRef'; 8 | export { useId } from './use-id'; 9 | -------------------------------------------------------------------------------- /libs/ui/core/dialog/index.ts: -------------------------------------------------------------------------------- 1 | // Components 2 | export { DialogContent } from './content'; 3 | 4 | export { DialogFooter } from './footer'; 5 | 6 | export { DialogHeader } from './header'; 7 | 8 | export { Dialog } from './dialog'; 9 | 10 | // Types 11 | export type { DialogContentProps } from './content'; 12 | 13 | export type { DialogFooterProps } from './footer'; 14 | 15 | export type { DialogHeaderProps } from './header'; 16 | 17 | export type { DialogProps } from './dialog'; 18 | -------------------------------------------------------------------------------- /libs/ui/core/input/input.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { forwardRef, ReactNode } from 'react'; 3 | 4 | export interface InputProps { 5 | className?: string; 6 | children: NonNullable; 7 | } 8 | 9 | export const Input = forwardRef(function Input({ children, className }: InputProps, ref: any) { 10 | return ( 11 |
12 | {children} 13 |
14 | ); 15 | }); 16 | 17 | Input.displayName = 'Input'; 18 | -------------------------------------------------------------------------------- /libs/ui/core/dialog/header.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from '../typography'; 2 | import clsx from 'clsx'; 3 | import { ReactNode } from 'react'; 4 | 5 | export interface DialogHeaderProps { 6 | className?: string; 7 | children: NonNullable; 8 | } 9 | 10 | export function DialogHeader({ children, className }: DialogHeaderProps) { 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /libs/ui/static/icons/CalendarToday.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /_templates/ui/new/component.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: libs/ui/core/<%=name%>/<%=name%>.tsx 3 | --- 4 | import { ReactNode } from 'react'; 5 | import clsx from 'clsx'; 6 | 7 | export interface <%=h.changeCase.pascalCase(name)%>Props { 8 | className?: string; 9 | children: NonNullable; 10 | } 11 | 12 | export function <%=h.changeCase.pascalCase(name)%>({ children, className }: <%=h.changeCase.pascalCase(name)%>Props) { 13 | return
{children}
; 14 | } 15 | -------------------------------------------------------------------------------- /libs/ui/icons/CalendarToday.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgCalendarToday', 4 | '0 0 24 24', 5 | , 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /libs/utils/core/array/difference.ts: -------------------------------------------------------------------------------- 1 | export function difference(targetA: T[], targetB: T[]): T[] { 2 | const set = new Set(); 3 | 4 | for (let index = 0; index < targetA.length; index++) { 5 | if (targetB.indexOf(targetA[index]) === -1) { 6 | set.add(targetA[index]); 7 | } 8 | } 9 | return Array.from(set); 10 | } 11 | 12 | export function symmetricDifference(targetA: T[], targetB: T[]) { 13 | return difference(targetA, targetB).concat(difference(targetB, targetA)); 14 | } 15 | -------------------------------------------------------------------------------- /libs/ui/icons/Visibility.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgVisibility', 4 | '0 0 24 24', 5 | , 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /_templates/ui/add-component/component.ejs.t: -------------------------------------------------------------------------------- 1 | --- 2 | to: libs/ui/core/<%=groupName%>/<%=name%>.tsx 3 | --- 4 | import { ReactNode } from 'react'; 5 | import clsx from 'clsx'; 6 | 7 | export interface <%=h.changeCase.pascalCase(name)%>Props { 8 | className?: string; 9 | children: NonNullable; 10 | } 11 | 12 | export function <%=h.changeCase.pascalCase(name)%>({ children, className }: <%=h.changeCase.pascalCase(name)%>Props) { 13 | return
{children}
; 14 | } 15 | -------------------------------------------------------------------------------- /apps/client-web/next.config.js: -------------------------------------------------------------------------------- 1 | const { createNextConfig, env } = require('@libs/config/next'); 2 | 3 | /** 4 | * @type {import('next').NextConfig} 5 | */ 6 | const configuration = { 7 | eslint: { 8 | ignoreDuringBuilds: true 9 | } 10 | }; 11 | 12 | module.exports = createNextConfig( 13 | { 14 | cwd: __dirname, 15 | workspaceDependencies: ['@libs/ui', '@libs/utils'], 16 | analyzer: { 17 | enabled: env.bool('ANALYZE'), 18 | detailed: true 19 | } 20 | }, 21 | configuration 22 | ); 23 | -------------------------------------------------------------------------------- /libs/ui/core/popover/types.ts: -------------------------------------------------------------------------------- 1 | export type PopoverPositionType = 'start' | 'center' | 'end'; 2 | export interface PopoverRect { 3 | width: number; 4 | height: number; 5 | } 6 | 7 | export interface PopoverOrigin { 8 | vertical: PopoverPositionType; 9 | horizontal: PopoverPositionType; 10 | } 11 | 12 | export interface PopoverRectOffset { 13 | top: number; 14 | left: number; 15 | } 16 | 17 | export interface PopoverElementPosition extends PopoverRectOffset { 18 | right: number; 19 | bottom: number; 20 | } 21 | -------------------------------------------------------------------------------- /libs/config/jest/ts-paths.js: -------------------------------------------------------------------------------- 1 | const { pathsToModuleNameMapper } = require('ts-jest'); 2 | 3 | module.exports = { 4 | createJestTSPathsConfig({ tsConfig, target }) { 5 | const { 6 | compilerOptions: { paths } 7 | } = require(tsConfig); 8 | 9 | if (!paths) return {}; 10 | return { 11 | moduleNameMapper: { 12 | // tslib: require.resolve('tslib'), 13 | ...pathsToModuleNameMapper(paths, { 14 | prefix: `/${target}` 15 | }) 16 | } 17 | }; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /libs/config/jest/index.js: -------------------------------------------------------------------------------- 1 | const { createJestBaseConfig } = require('./base'); 2 | const { createJestTSPathsConfig } = require('./ts-paths'); 3 | 4 | module.exports = { 5 | createJestProjectConfig({ displayName, pathsRoot, targets, tsConfig }) { 6 | return { 7 | displayName, 8 | ...createJestBaseConfig({ 9 | rootFolders: targets, 10 | tsconfig: tsConfig 11 | }), 12 | ...createJestTSPathsConfig({ 13 | tsConfig, 14 | target: pathsRoot 15 | }) 16 | }; 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /libs/ui/static/icons/Visibility.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # ENV 2 | 3 | **/.env.local 4 | **/.env.*.local 5 | 6 | # node_modules and dist folders 7 | 8 | apps/**/.next 9 | node_modules 10 | **/node_modules 11 | 12 | # Yarn 13 | 14 | .yarn/* 15 | !.yarn/cache 16 | !.yarn/releases 17 | !.yarn/plugins 18 | !.yarn/sdks 19 | !.yarn/versions 20 | 21 | # Cache 22 | 23 | .cache/* 24 | tsconfig.tsbuildinfo 25 | .eslintcache 26 | 27 | # GIT 28 | 29 | .git 30 | .gitignore 31 | .gitattributes 32 | .github 33 | 34 | # IDE 35 | 36 | .idea 37 | .DS_Store 38 | 39 | # Logs 40 | 41 | logs 42 | *.log 43 | -------------------------------------------------------------------------------- /apps/todo-web/next.config.js: -------------------------------------------------------------------------------- 1 | const { createNextConfig, env } = require('@libs/config/next'); 2 | 3 | /** 4 | * @type {import('next').NextConfig} 5 | */ 6 | const configuration = { 7 | experimental: { 8 | esmExternals: false, 9 | externalDir: false 10 | }, 11 | eslint: { 12 | ignoreDuringBuilds: true 13 | } 14 | }; 15 | 16 | module.exports = createNextConfig( 17 | { 18 | cwd: __dirname, 19 | workspaceDependencies: ['@libs/ui', '@libs/utils'], 20 | enableBundleAnalyzer: env.bool('ANALYZE') 21 | }, 22 | configuration 23 | ); 24 | -------------------------------------------------------------------------------- /apps/client-web/README.md: -------------------------------------------------------------------------------- 1 | # client-web 2 | 3 | ## Libraries 4 | 5 | ### Material UI 6 | 7 | - Support for SSR 8 | - Build optimizations - includes only used components 9 | - Correct theme providing 10 | 11 | ### Styled components 12 | 13 | - Support for SSR 14 | - Integration with material-ui`s style system 15 | 16 | ## Features/examples 17 | 18 | - Page loading display by nprogress 19 | - Multiple layouts depends on page 20 | - Custom theming 21 | - styled-components + material-ui ssr and correct styles ordering 22 | - Build optimizations 23 | - .env files support 24 | -------------------------------------------------------------------------------- /libs/ui/core/input/types.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElementCoreProps, KeyboardProps } from '../../types'; 2 | import { ChangeEvent, HTMLInputTypeAttribute } from 'react'; 3 | 4 | export interface InputStatesProps { 5 | invalid?: boolean; 6 | focused?: boolean; 7 | loading?: boolean; 8 | disabled?: boolean; 9 | } 10 | 11 | export interface InputSharedProps extends InputStatesProps, HTMLElementCoreProps, KeyboardProps { 12 | type?: HTMLInputTypeAttribute; 13 | value?: string; 14 | onChange?(e: ChangeEvent): void; 15 | placeholder?: string; 16 | } 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { createJestProjectConfig } = require('@libs/config/jest'); 2 | 3 | module.exports = { 4 | projects: [ 5 | createJestProjectConfig({ 6 | displayName: '@apps/client-web', 7 | pathsRoot: 'apps/client-web', 8 | tsConfig: require.resolve('./apps/client-web/tsconfig.node.json'), 9 | targets: ['apps/client-web'] 10 | }), 11 | createJestProjectConfig({ 12 | displayName: '@libs/<*>', 13 | targets: ['libs/ui', 'libs/utils'], 14 | tsConfig: require.resolve('./tsconfig.node.json') 15 | }) 16 | ] 17 | }; 18 | -------------------------------------------------------------------------------- /libs/ui/index.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | 3 | module.exports = { 4 | getTailwindConfig({ cwd }) { 5 | /** @type {import('tailwindcss/tailwind-config').TailwindConfig} */ 6 | return { 7 | ...require('./tailwind.config'), 8 | content: ['./**/*.{tsx,css}', '../../libs/ui/**/*.{tsx,ts}'] 9 | }; 10 | }, 11 | getPostCSSConfig({ cwd }) { 12 | return { 13 | plugins: { 14 | tailwindcss: { 15 | config: join(cwd, 'tailwind.config.js') 16 | }, 17 | autoprefixer: {} 18 | } 19 | }; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /libs/ui/lib/dom/style.ts: -------------------------------------------------------------------------------- 1 | import { getOwnerWindow } from './shared'; 2 | 3 | export const setStyle = ({ value, element, property }: StyleDescriptor) => 4 | !value ? element.style.removeProperty(property) : element.style.setProperty(property, value); 5 | 6 | export const getPaddingRight = (element: Element) => 7 | parseInt(getOwnerWindow(element).getComputedStyle(element).paddingRight, 10) || 0; 8 | 9 | export interface StyleDescriptor { 10 | element: HTMLElement; 11 | /** 12 | * CSS property name (HYPHEN CASE) to be modified. 13 | */ 14 | property: string; 15 | value: string | null; 16 | } 17 | -------------------------------------------------------------------------------- /libs/ui/core/button/button.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Spaced } from '../grid'; 2 | import { Button, ButtonProps } from './button'; 3 | 4 | export default { 5 | title: 'Components/Button' 6 | }; 7 | 8 | export const Appearance = (props: ButtonProps) => ( 9 | 10 | 13 | 16 | 19 | 20 | ); 21 | 22 | Appearance.args = { 23 | children: 'Button' 24 | }; 25 | -------------------------------------------------------------------------------- /libs/ui/types/common.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentPropsWithoutRef, 3 | ComponentPropsWithRef, 4 | ElementType, 5 | JSXElementConstructor 6 | } from 'react'; 7 | 8 | export type Nil = null | void | undefined; 9 | 10 | export type RefOf = ComponentPropsWithRef['ref']; 11 | 12 | export type HTMLElementType = keyof JSX.IntrinsicElements; 13 | 14 | export type PropsOf> = 15 | JSX.LibraryManagedAttributes>; 16 | 17 | export type MergeProps = Base & Omit; 18 | -------------------------------------------------------------------------------- /libs/ui/types/global.overrides.d.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * @see https://github.com/DefinitelyTyped/DefinitelyTyped/issues/37087#issuecomment-726158907 5 | */ 6 | 7 | declare module 'react' { 8 | function memo>( 9 | c: T, 10 | areEqual?: ( 11 | prev: Readonly>, 12 | next: Readonly> 13 | ) => boolean 14 | ): T & { 15 | displayName?: string | undefined; 16 | }; 17 | 18 | interface CSSProperties extends React.CSSProperties { 19 | [key: `--${string}`]: string | number | null | void; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /libs/ui/hooks/core/useEventCallback.ts: -------------------------------------------------------------------------------- 1 | import { useUniversalLayoutEffect } from './useUniversalLayoutEffect'; 2 | import { useCallback, useRef } from 'react'; 3 | 4 | /** 5 | * Callback for user events without any revalidation 6 | * https://github.com/facebook/react/issues/14099#issuecomment-440013892 7 | */ 8 | export function useEventCallback( 9 | fn: (...args: Args) => Return 10 | ): (...args: Args) => Return { 11 | const ref = useRef(fn); 12 | 13 | useUniversalLayoutEffect(() => { 14 | ref.current = fn; 15 | }); 16 | return useCallback((...args: Args) => ref.current!(...args), []); 17 | } 18 | -------------------------------------------------------------------------------- /libs/ui/core/card/paper.tsx: -------------------------------------------------------------------------------- 1 | import { PropsOf } from '../../types'; 2 | import clsx from 'clsx'; 3 | import { ForwardedRef, forwardRef, ReactNode } from 'react'; 4 | 5 | export interface PaperProps extends PropsOf<'div'> { 6 | children?: ReactNode; 7 | disablePadding?: boolean; 8 | } 9 | 10 | export const Paper = forwardRef( 11 | ( 12 | { children, className, disablePadding, ...props }: PaperProps, 13 | ref: ForwardedRef 14 | ) => ( 15 |
16 | {children} 17 |
18 | ) 19 | ); 20 | 21 | Paper.displayName = 'Paper'; 22 | -------------------------------------------------------------------------------- /libs/ui/lib/dom/events.ts: -------------------------------------------------------------------------------- 1 | import { SyntheticEvent } from 'react'; 2 | 3 | export const stopEvent = (e: Event | SyntheticEvent) => { 4 | e.stopPropagation(); 5 | e.preventDefault(); 6 | }; 7 | 8 | /** 9 | * Shortcut for add/remove event (ex. in effects) 10 | */ 11 | export const subscribeToEvent = ( 12 | target: T, 13 | type: K, 14 | handler: (event: HTMLElementEventMap[K]) => any, 15 | options?: boolean | AddEventListenerOptions 16 | ) => { 17 | target.addEventListener(type, handler, options); 18 | return () => target.removeEventListener(type, handler, options); 19 | }; 20 | -------------------------------------------------------------------------------- /libs/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@libs/config", 3 | "version": "0.0.1", 4 | "packageManager": "yarn@3.2.0", 5 | "dependencies": { 6 | "@next/bundle-analyzer": "^12.1.1", 7 | "@types/jest": "^27.4.1", 8 | "@types/node": "^17.0.23", 9 | "next-compose-plugins": "^2.2.1", 10 | "next-transpile-modules": "^9.0.0", 11 | "ts-jest": "^27.1.4", 12 | "typescript": "^4.6.3" 13 | }, 14 | "peerDependencies": { 15 | "jest": "^27.5.1", 16 | "next": "^12.1.0" 17 | }, 18 | "peerDependenciesMeta": { 19 | "jest": { 20 | "optional": true 21 | }, 22 | "next": { 23 | "optional": true 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /libs/ui/hooks/core/useForkCallback.ts: -------------------------------------------------------------------------------- 1 | import { Nil } from '../../types'; 2 | import { useVariableRef } from './useVariableRef'; 3 | import { useCallback } from 'react'; 4 | 5 | export function useForkCallback( 6 | callbackA: ((...args: Args) => any) | Nil, 7 | callbackB: ((...args: Args) => any) | Nil 8 | ): ((...args: Args) => any) | undefined { 9 | const callbacksRef = useVariableRef({ callbackA, callbackB }); 10 | 11 | return useCallback((...args: Args) => { 12 | if (callbacksRef.current?.callbackA) callbacksRef.current.callbackA(...args); 13 | if (callbacksRef.current?.callbackB) callbacksRef.current.callbackB(...args); 14 | }, []); 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | **/_ignore-*/ 4 | 5 | coverage 6 | 7 | tsconfig.tsbuildinfo 8 | .eslintcache 9 | *.log 10 | **/*.local 11 | 12 | **/node_modules/ 13 | 14 | apps/**/dist 15 | libs/**/dist 16 | shared/**/dist 17 | tools/**/dist 18 | 19 | # ========== Yarn ========== 20 | 21 | # https://yarnpkg.com/advanced/qa#which-files-should-be-gitignored but nested for e2e directories 22 | .yarn/* 23 | !.yarn/releases 24 | !.yarn/plugins 25 | !.yarn/sdks 26 | !.yarn/versions 27 | 28 | # Uncommeth these lines if you're using Zero-Installs 29 | # !.yarn/cache 30 | 31 | # Uncommeth these lines if you're NOT using Zero-Installs 32 | .pnp.* 33 | 34 | # ========== Yarn ========== 35 | -------------------------------------------------------------------------------- /libs/utils/core/array/difference.test.ts: -------------------------------------------------------------------------------- 1 | import { difference, symmetricDifference } from './difference'; 2 | 3 | describe('difference utils', () => { 4 | test('difference', () => { 5 | expect(difference([1, 2, 3, 4], [4, 5])).toEqual([1, 2, 3]); 6 | expect(difference([4, 5], [1, 2, 3, 4])).toEqual([5]); 7 | expect(difference([1, 2, 3, 1, 2, 3, 1, 2, 3, 4], [1, 2])).toEqual([3, 4]); 8 | }); 9 | 10 | test('symmetricDifference', () => { 11 | expect(symmetricDifference([1, 2, 3], [1, 4])).toEqual([2, 3, 4]); 12 | expect(symmetricDifference([1, 2, 3], [])).toEqual([1, 2, 3]); 13 | expect(symmetricDifference([], [1, 2, 3])).toEqual([1, 2, 3]); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /libs/ui/core/dialog/dialog.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '../button'; 2 | import { Spaced } from '../grid'; 3 | import { DialogContent } from './content'; 4 | import { Dialog, DialogProps } from './dialog'; 5 | import { Meta, Story } from '@storybook/react/types-6-0'; 6 | 7 | export default { 8 | title: 'Components/Dialog', 9 | component: Dialog 10 | } as Meta; 11 | 12 | export const WithButton: Story = props => ( 13 | 14 | 15 |
Popover example
16 | 17 | 18 | 19 | 20 |
21 |
22 | ); 23 | -------------------------------------------------------------------------------- /libs/ui/stories/Icons/Icons.stories.mdx: -------------------------------------------------------------------------------- 1 | import * as icons from '@libs/ui/icons'; import { IconsPreview } from './icons-preview'; 2 | 3 | 4 | 5 | # Icons 6 | 7 | All icons was generated via svgr 8 | 9 | ## Usage 10 | 11 | ```jsx 12 | import { AddIcon } from '@libs/ui/icons'; 13 | 14 | function MyComponent() { 15 | return ( 16 | <> 17 | } /> 18 | 19 | 20 | ... 21 | 22 | 23 | ) 24 | } 25 | ``` 26 | 27 |
28 | 29 |
30 | -------------------------------------------------------------------------------- /libs/ui/core/transition/constants.ts: -------------------------------------------------------------------------------- 1 | export const TransitionDuration = { 2 | standard: 300, 3 | enteringScreen: 225, 4 | leavingScreen: 195 5 | }; 6 | 7 | export const TransitionEasing = { 8 | // This is the most common easing curve. 9 | easeInOut: 'cubic-bezier(0.4, 0, 0.2, 1)', 10 | // Objects enter the screen at full velocity from off-screen and 11 | // slowly decelerate to a resting point. 12 | easeOut: 'cubic-bezier(0.0, 0, 0.2, 1)', 13 | // Objects leave the screen at full velocity. They do not decelerate when off-screen. 14 | easeIn: 'cubic-bezier(0.4, 0, 1, 1)', 15 | // The sharp curve is used by objects that may return to the screen at any time. 16 | sharp: 'cubic-bezier(0.4, 0, 0.6, 1)' 17 | }; 18 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "preserve", 4 | "lib": ["esnext", "dom"], 5 | "target": "esnext", 6 | "module": "esnext", 7 | "esModuleInterop": true, 8 | "moduleResolution": "node", 9 | "strictFunctionTypes": true, 10 | "strict": true, 11 | "sourceMap": true, 12 | "skipLibCheck": true, 13 | "isolatedModules": true, 14 | "importHelpers": true, 15 | "noUnusedLocals": true, 16 | "resolveJsonModule": true, 17 | "noImplicitReturns": true, 18 | "noErrorTruncation": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "experimentalDecorators": true, 22 | "forceConsistentCasingInFileNames": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /libs/config/ts/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "preserve", 4 | "lib": ["esnext", "dom"], 5 | "target": "esnext", 6 | "module": "esnext", 7 | "esModuleInterop": true, 8 | "moduleResolution": "node", 9 | "strictFunctionTypes": true, 10 | "strict": true, 11 | "sourceMap": true, 12 | "skipLibCheck": true, 13 | "isolatedModules": true, 14 | "importHelpers": true, 15 | "noUnusedLocals": true, 16 | "resolveJsonModule": true, 17 | "noImplicitReturns": true, 18 | "noErrorTruncation": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "experimentalDecorators": true, 22 | "forceConsistentCasingInFileNames": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /libs/ui/hooks/focus-trap/useFocusReturnToLast.ts: -------------------------------------------------------------------------------- 1 | import { useDidUpdateEffect } from '../core'; 2 | import { useRef } from 'react'; 3 | 4 | export function useFocusReturnToLast(open: boolean, disabled = false) { 5 | const targetRef = useRef(null); 6 | 7 | useDidUpdateEffect(() => { 8 | if (open) { 9 | targetRef.current = document.activeElement ?? targetRef.current; 10 | } else if (!disabled && isFocusable(targetRef.current)) { 11 | targetRef.current.focus(); 12 | targetRef.current = null; 13 | } 14 | }, [open]); 15 | } 16 | 17 | const isFocusable = (element: Element | null): element is HTMLElement => 18 | element !== null && 'focus' in element && typeof (element as HTMLElement).focus === 'function'; 19 | -------------------------------------------------------------------------------- /Taskfile.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | tasks: 3 | ws:new: 4 | summary: Create new package 5 | cmds: 6 | - hygen ws new 7 | ui:new: 8 | summary: Create new UI component 9 | cmds: 10 | - hygen ui new 11 | ui:add-component: 12 | summary: Add component to exists folder 13 | cmds: 14 | - hygen ui add-component 15 | ui:build: 16 | cmds: 17 | - yarn workspace @libs/ui build 18 | app:build: 19 | cmds: 20 | - yarn workspace @apps/client-web build 21 | build: 22 | deps: 23 | - ui:build 24 | - app:build 25 | 26 | cleanup: 27 | cmds: 28 | - yarn dlx rimraf apps/**/.next/ **/node_modules/.cache/ 29 | 30 | check: 31 | cmds: 32 | - yarn lint 33 | - yarn jest 34 | -------------------------------------------------------------------------------- /apps/todo-web/features/toggle-task/ui.tsx: -------------------------------------------------------------------------------- 1 | import { useEvent } from 'effector-react'; 2 | import { memo, useCallback } from 'react'; 3 | import { TaskModel } from '@/entities/task'; 4 | import { Task } from '@/shared/types'; 5 | 6 | export interface ToggleTaskProps { 7 | task: Task; 8 | } 9 | 10 | export const ToggleTask = memo(({ task }: ToggleTaskProps) => { 11 | const updateTask = useEvent(TaskModel.updateTask); 12 | const handleChange = useCallback( 13 | e => 14 | updateTask({ 15 | id: task.id, 16 | completed: e.target.checked 17 | }), 18 | [task.id] 19 | ); 20 | 21 | return ; 22 | }); 23 | 24 | ToggleTask.displayName = 'ToggleTask'; 25 | -------------------------------------------------------------------------------- /libs/ui/icons/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgNotifications', 4 | '0 0 30 30', 5 | 10 | ); 11 | -------------------------------------------------------------------------------- /libs/ui/static/icons/Help.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /libs/ui/icons/Help.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgHelp', 4 | '0 0 24 24', 5 | , 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /libs/utils/core/function/debounce.test.ts: -------------------------------------------------------------------------------- 1 | import { debounce } from './debounce'; 2 | 3 | describe('debounce', () => { 4 | beforeEach(() => jest.useFakeTimers('modern')); 5 | afterEach(() => jest.useRealTimers()); 6 | 7 | test('Should fires once', () => { 8 | const debounced = debounce(jest.fn()); 9 | 10 | debounced(1); 11 | debounced(1); 12 | debounced(2); 13 | debounced(3); 14 | jest.runAllTimers(); 15 | expect(debounced.fn).toHaveBeenCalledTimes(1); 16 | expect(debounced.fn).toHaveBeenCalledWith(3); 17 | }); 18 | test('Should be cleared', () => { 19 | const debounced = debounce(jest.fn()); 20 | 21 | debounced(1); 22 | debounced.clear(); 23 | jest.runAllTimers(); 24 | expect(debounced.fn).not.toHaveBeenCalled(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /apps/todo-web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apps/todo-web", 3 | "version": "0.0.1", 4 | "packageManager": "yarn@3.2.0", 5 | "dependencies": { 6 | "@libs/ui": "workspace:*", 7 | "@libs/utils": "workspace:*", 8 | "clsx": "^1.1.1", 9 | "effector": "^22.2.0", 10 | "effector-react": "^22.0.6", 11 | "next": "^12.1.1", 12 | "react": "^17.0.2", 13 | "react-dom": "^17.0.2" 14 | }, 15 | "devDependencies": { 16 | "@libs/config": "workspace:*", 17 | "@types/react": "^17.0.43", 18 | "@types/react-dom": "^17.0.14", 19 | "typescript": "^4.6.3" 20 | }, 21 | "scripts": { 22 | "dev": "next dev", 23 | "build": "next build", 24 | "start": "next start", 25 | "cleanup": "yarn dlx rimraf .next", 26 | "check:typescript": "tsc --noEmit" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "baseUrl": ".", 5 | "jsx": "preserve", 6 | "lib": ["esnext", "dom"], 7 | "types": ["jest", "node"], 8 | "target": "esnext", 9 | "module": "esnext", 10 | "esModuleInterop": true, 11 | "moduleResolution": "node", 12 | "strictFunctionTypes": true, 13 | "strict": true, 14 | "sourceMap": true, 15 | "skipLibCheck": true, 16 | "isolatedModules": true, 17 | "importHelpers": true, 18 | "noUnusedLocals": true, 19 | "resolveJsonModule": true, 20 | "noImplicitReturns": true, 21 | "noErrorTruncation": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "experimentalDecorators": true, 25 | "forceConsistentCasingInFileNames": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /libs/utils/core/core.ts: -------------------------------------------------------------------------------- 1 | export const toArray = (value: T | T[]): T[] => (Array.isArray(value) ? value : [value]); 2 | 3 | export const eq = (left: unknown, right: unknown): boolean => { 4 | if (Object.is(left, right)) return true; 5 | const type = getObjectType(left); 6 | 7 | if (type !== getObjectType(right)) return false; 8 | switch (type) { 9 | case 'Array': 10 | return eqArray(left as unknown[], right as unknown[]); 11 | // TODO Add Object support 12 | default: 13 | return false; 14 | } 15 | }; 16 | 17 | export const eqArray = (left: T[], right: T[]): boolean => 18 | left.length === right.length && left.every((value, index) => eq(value, right[index])); 19 | 20 | const getObjectType = (value: unknown) => 21 | value === null ? 'Null' : Object.prototype.toString.call(value).slice(8, -1); 22 | -------------------------------------------------------------------------------- /libs/ui/hooks/core/useForkRef.ts: -------------------------------------------------------------------------------- 1 | import { getChildrenRef, mergeTernaryRefs } from '../../lib/refs'; 2 | import { Nil } from '../../types'; 3 | import { ReactNode, Ref, useMemo } from 'react'; 4 | 5 | export function useForkRef( 6 | refA: Ref | Nil, 7 | refB: Ref | Nil 8 | ): Ref | null { 9 | /** 10 | * This will create a new function if the ref props change and are defined. 11 | * This means react will call the old forkRef with `null` and the new forkRef 12 | * with the ref. Cleanup naturally emerges from this behavior. 13 | */ 14 | return useMemo(() => (refA || refB ? mergeTernaryRefs(refA, refB) : null), [refA, refB]); 15 | } 16 | 17 | export function useChildrenForkRef(children: ReactNode, ref: Ref | Nil) { 18 | return useForkRef(getChildrenRef(children), ref); 19 | } 20 | -------------------------------------------------------------------------------- /libs/utils/core/function/debounce.ts: -------------------------------------------------------------------------------- 1 | export interface Debounced { 2 | (this: This, ...args: Args): void; 3 | clear(): void; 4 | /** 5 | * Original function 6 | */ 7 | fn(this: This, ...args: Args): any; 8 | } 9 | 10 | // Corresponds to 10 frames at 60 Hz. 11 | // A few bytes payload overhead when lodash/debounce is ~3 kB and debounce ~300 B. 12 | export function debounce( 13 | fn: (this: This, ...args: Args) => any, 14 | wait = 166 15 | ): Debounced { 16 | let timeout: ReturnType; 17 | 18 | function debounced(this: This, ...args: Args) { 19 | debounced.clear(); 20 | timeout = setTimeout(() => fn.apply(this, args), wait); 21 | } 22 | debounced.fn = fn; 23 | debounced.clear = () => clearTimeout(timeout); 24 | return debounced; 25 | } 26 | -------------------------------------------------------------------------------- /apps/todo-web/entities/task/ui/list-item.tsx: -------------------------------------------------------------------------------- 1 | import { Paper } from '@libs/ui/core/card'; 2 | import clsx from 'clsx'; 3 | import { ReactNode } from 'react'; 4 | import { Task } from '@/shared/types'; 5 | 6 | export interface TaskListItemProps { 7 | task: Task; 8 | endNode?: ReactNode; 9 | startNode?: ReactNode; 10 | } 11 | 12 | export function TaskListItem({ 13 | task: { completed, label }, 14 | startNode, 15 | endNode 16 | }: TaskListItemProps) { 17 | return ( 18 | 19 | {startNode &&
{startNode}
} 20 |
23 | {label} 24 |
25 | {endNode &&
{endNode}
} 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/client-web/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "production": { 4 | "presets": [ 5 | [ 6 | "next/babel", 7 | { 8 | "preset-env": { 9 | "targets": [">0.3%", "not ie 11", "not dead", "not op_mini all"] 10 | }, 11 | "transform-runtime": { 12 | "helpers": true, 13 | "version": "7.11.5" 14 | } 15 | } 16 | ] 17 | ] 18 | }, 19 | "development": { 20 | "presets": [ 21 | [ 22 | "next/babel", 23 | { 24 | "preset-env": { 25 | "targets": [ 26 | "last 1 chrome version", 27 | "last 1 firefox version", 28 | "last 1 safari version" 29 | ] 30 | } 31 | } 32 | ] 33 | ] 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /apps/todo-web/features/remove-task/ui.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorIcon } from '@libs/ui/icons'; 2 | import { useEvent } from 'effector-react'; 3 | import { memo, useCallback } from 'react'; 4 | import { TaskModel } from '@/entities/task'; 5 | import { Task } from '@/shared/types'; 6 | 7 | export interface RemoveTaskProps { 8 | task: Task; 9 | } 10 | 11 | export const RemoveTask = memo(({ task: { id } }: RemoveTaskProps) => { 12 | const remove = useEvent(TaskModel.removeTaskById); 13 | const handleClick = useCallback(() => remove(id), [remove, id]); 14 | 15 | return ( 16 | 22 | ); 23 | }); 24 | 25 | RemoveTask.displayName = 'RemoveTask'; 26 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: pull-request 2 | on: pull_request 3 | jobs: 4 | ValidateCode: 5 | name: Check linting, TypeScript and run tests 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: 14.x 13 | 14 | - name: Get yarn cache directory path 15 | id: yarn-cache-dir-path 16 | run: echo "::set-output name=dir::$(yarn config get cacheFolder)" 17 | 18 | - uses: actions/cache@v2 19 | id: yarn-cache 20 | with: 21 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 22 | key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} 23 | restore-keys: | 24 | ${{ runner.os }}-yarn- 25 | 26 | - run: yarn install --immutable 27 | - run: yarn lint 28 | - run: yarn jest 29 | -------------------------------------------------------------------------------- /libs/ui/core/popover/popover.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '../button'; 2 | import { Spaced } from '../grid'; 3 | import { Popover } from './popover'; 4 | import { Meta } from '@storybook/react/types-6-0'; 5 | import { useState } from 'react'; 6 | 7 | export default { 8 | title: 'Components/Popover', 9 | component: Popover 10 | } as Meta; 11 | 12 | export const WithButton = () => { 13 | const [anchorEl, setAnchorEl] = useState(null); 14 | 15 | return ( 16 | <> 17 | setAnchorEl(null)}> 18 |
Popover example
19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /apps/client-web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apps/client-web", 3 | "dependencies": { 4 | "@libs/ui": "workspace:*", 5 | "next": "^12.1.1", 6 | "react": "^17.0.2", 7 | "react-dom": "^17.0.2" 8 | }, 9 | "devDependencies": { 10 | "@libs/config": "workspace:*", 11 | "@next/bundle-analyzer": "^12.1.1", 12 | "@types/node": "^17.0.23", 13 | "@types/react": "^17.0.43", 14 | "@types/react-dom": "^17.0.14", 15 | "@types/tailwindcss": "^3.0.9", 16 | "autoprefixer": "^10.4.4", 17 | "next-compose-plugins": "^2.2.1", 18 | "next-transpile-modules": "^9.0.0", 19 | "postcss": "^8.4.12", 20 | "tailwindcss": "^3.0.23", 21 | "typescript": "^4.6.3" 22 | }, 23 | "scripts": { 24 | "dev": "next dev", 25 | "build": "next build", 26 | "start": "next start", 27 | "cleanup": "yarn dlx rimraf .next", 28 | "check:typescript": "tsc --noEmit" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /libs/ui/core/modal/backdrop.tsx: -------------------------------------------------------------------------------- 1 | import { Transition } from '../transition/transition'; 2 | import clsx from 'clsx'; 3 | import { MouseEvent } from 'react'; 4 | 5 | export interface BackdropProps { 6 | invisible?: boolean; 7 | blurred?: boolean; 8 | open?: boolean; 9 | onClick?(e: MouseEvent): void; 10 | } 11 | 12 | export function Backdrop({ invisible, onClick, open, blurred }: BackdropProps) { 13 | const element = ( 14 |
23 | ); 24 | 25 | if (invisible) { 26 | return open ? element : null; 27 | } 28 | return ( 29 | 30 | {element} 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /libs/ui/icons/VisibilityOff.tsx: -------------------------------------------------------------------------------- 1 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 2 | export default createSvgIcon( 3 | 'SvgVisibilityOff', 4 | '0 0 24 24', 5 | , 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /libs/ui/static/icons/VisibilityOff.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /libs/ui/types/theme.ts: -------------------------------------------------------------------------------- 1 | export type UISize = 'sm' | 'md' | 'lg'; 2 | 3 | /** 4 | * Palette 5 | */ 6 | export type UIMainColorName = 'primary' | 'secondary' | 'error' | 'warning' | 'success'; 7 | 8 | export interface UIPalette { 9 | gray: UIGradientColor; 10 | 11 | white: string; 12 | black: string; 13 | 14 | primary: UIRegularColor; 15 | secondary: UIRegularColor; 16 | 17 | error: UIRegularColor; 18 | warning: UIRegularColor; 19 | success: UIRegularColor; 20 | } 21 | 22 | export interface UIRegularColor { 23 | main: string; 24 | dark: string; 25 | light: string; 26 | } 27 | 28 | export interface UITextColor { 29 | body: string; 30 | label: string; 31 | } 32 | 33 | export interface UIGradientColor { 34 | '50': string; 35 | '100': string; 36 | '200': string; 37 | '300': string; 38 | '400': string; 39 | '500': string; 40 | '600': string; 41 | '700': string; 42 | '800': string; 43 | '900': string; 44 | } 45 | -------------------------------------------------------------------------------- /libs/ui/static/icons/Notifications.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /libs/ui/lib/dom/overflow.ts: -------------------------------------------------------------------------------- 1 | import { getDocumentWindow, getOwnerDocument } from './shared'; 2 | 3 | // Is a vertical scrollbar displayed? 4 | export function isOverflowing(container: Element): boolean { 5 | const ownerDocument = getOwnerDocument(container); 6 | 7 | if (ownerDocument.body === container) { 8 | return getDocumentWindow(ownerDocument).innerWidth > ownerDocument.documentElement.clientWidth; 9 | } 10 | 11 | return container.scrollHeight > container.clientHeight; 12 | } 13 | 14 | // A change of the browser zoom change the scrollbar size. 15 | // Credit https://github.com/twbs/bootstrap/blob/488fd8afc535ca3a6ad4dc581f5e89217b6a36ac/js/src/util/scrollbar.js#L14-L18 16 | export function getScrollbarSize(doc: Document): number { 17 | // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes 18 | const documentWidth = doc.documentElement.clientWidth; 19 | 20 | return Math.abs(window.innerWidth - documentWidth); 21 | } 22 | -------------------------------------------------------------------------------- /libs/ui/core/grid/spaced.tsx: -------------------------------------------------------------------------------- 1 | import styles from './grid.module.css'; 2 | import clsx from 'clsx'; 3 | import { CSSProperties, ReactNode } from 'react'; 4 | 5 | export interface SpacedProps { 6 | className?: string; 7 | size?: number; // TODO Add size 8 | children: NonNullable; 9 | direction?: 'row' | 'column'; 10 | wrap?: boolean; 11 | align?: 'start' | 'end' | 'center'; 12 | } 13 | 14 | export function Spaced({ children, className, size = 16, direction = 'row', wrap }: SpacedProps) { 15 | return ( 16 |
29 | {children} 30 |
31 | ); 32 | } 33 | 34 | const directions = { 35 | row: 'flex-row', 36 | column: 'flex-col' 37 | }; 38 | -------------------------------------------------------------------------------- /apps/todo-web/screens/todos/index.tsx: -------------------------------------------------------------------------------- 1 | import { useList } from 'effector-react'; 2 | import { memo } from 'react'; 3 | import { TaskListItem, TaskModel } from '@/entities/task'; 4 | import { AddTaskInput } from '@/features/add-task'; 5 | import { RemoveTask } from '@/features/remove-task'; 6 | import { ToggleTask } from '@/features/toggle-task'; 7 | 8 | export const TodosScreen = memo(() => { 9 | return ( 10 |
11 |

Todos

12 |
13 | 14 |
15 | {useList(TaskModel.tasksList, { 16 | fn: task => ( 17 | } 20 | startNode={} 21 | /> 22 | ), 23 | getKey: task => task.id 24 | })} 25 |
26 | ); 27 | }); 28 | 29 | TodosScreen.displayName = 'TodosScreen'; 30 | -------------------------------------------------------------------------------- /apps/todo-web/entities/task/model.ts: -------------------------------------------------------------------------------- 1 | import { createEvent, createStore } from 'effector'; 2 | import { Task, TaskCreationParams, TaskID, TaskUpdateParams } from '@/shared/types'; 3 | 4 | export const tasksList = createStore([ 5 | { 6 | id: '0', 7 | label: 'First example', 8 | completed: true 9 | } 10 | ]); 11 | 12 | export const addTask = createEvent(); 13 | 14 | tasksList.on(addTask, (prev, { label }) => 15 | prev.concat({ 16 | id: prev.length.toString(), 17 | label, 18 | completed: false 19 | }) 20 | ); 21 | 22 | export const removeTaskById = createEvent(); 23 | export const removeTask = removeTaskById.prepend(task => task.id); 24 | 25 | tasksList.on(removeTaskById, (prev, id) => prev.filter(task => task.id !== id)); 26 | 27 | export const updateTask = createEvent(); 28 | 29 | tasksList.on(updateTask, (prev, updates) => 30 | prev.map(task => (task.id === updates.id ? { ...task, ...updates } : task)) 31 | ); 32 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'body-leading-blank': [1, 'always'], 5 | 'body-max-line-length': [2, 'always', 120], 6 | 'footer-leading-blank': [1, 'always'], 7 | 'footer-max-line-length': [2, 'always', 120], 8 | 'header-max-length': [2, 'always', 120], 9 | 'scope-case': [2, 'always', 'lower-case'], 10 | 'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']], 11 | 'subject-empty': [2, 'never'], 12 | 'subject-full-stop': [2, 'never', '.'], 13 | 'type-case': [2, 'always', 'lower-case'], 14 | 'type-empty': [2, 'never'], 15 | 'type-enum': [ 16 | 2, 17 | 'always', 18 | [ 19 | 'build', 20 | 'chore', 21 | 'ci', 22 | 'docs', 23 | 'feat', 24 | 'fix', 25 | 'perf', 26 | 'refactor', 27 | 'revert', 28 | 'style', 29 | 'test', 30 | 'security' 31 | ] 32 | ] 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /libs/ui/hooks/keyboard/useHotkey.ts: -------------------------------------------------------------------------------- 1 | import { subscribeToEvent } from '../../lib/dom'; 2 | import { getHotkeyHandler, isNonInputKeyboardEvent } from './lib'; 3 | import { HotkeyEvent, HotkeyHandlerEntry } from './types'; 4 | import React, { DependencyList, useEffect, useMemo } from 'react'; 5 | 6 | export function useGlobalHotkey( 7 | handlers: Array>, 8 | deps?: DependencyList 9 | ) { 10 | useEffect(() => { 11 | const hotkeyHandler = getHotkeyHandler(handlers); 12 | const keydownListener = (event: KeyboardEvent) => { 13 | if (isNonInputKeyboardEvent(event)) { 14 | hotkeyHandler(event); 15 | } 16 | }; 17 | 18 | return subscribeToEvent(document.documentElement, 'keydown', keydownListener); 19 | }, deps ?? [handlers]); 20 | } 21 | 22 | export function useHotkeyCallback( 23 | handlers: Array>, 24 | deps?: DependencyList 25 | ) { 26 | return useMemo(() => getHotkeyHandler(handlers), deps ?? [handlers]); 27 | } 28 | -------------------------------------------------------------------------------- /libs/ui/icons/index.ts: -------------------------------------------------------------------------------- 1 | import AddIcon from './Add'; 2 | import ArrowDropDownIcon from './ArrowDropDown'; 3 | import ArrowDropUpIcon from './ArrowDropUp'; 4 | import CalendarTodayIcon from './CalendarToday'; 5 | import DoneIcon from './Done'; 6 | import ErrorIcon from './Error'; 7 | import HelpIcon from './Help'; 8 | import InfoIcon from './Info'; 9 | import KeyboardArrowDownIcon from './KeyboardArrowDown'; 10 | import KeyboardArrowLeftIcon from './KeyboardArrowLeft'; 11 | import KeyboardArrowRightIcon from './KeyboardArrowRight'; 12 | import NotificationsIcon from './Notifications'; 13 | import VisibilityIcon from './Visibility'; 14 | import VisibilityOffIcon from './VisibilityOff'; 15 | import WarnIcon from './Warn'; 16 | 17 | export { 18 | AddIcon, 19 | ArrowDropDownIcon, 20 | ArrowDropUpIcon, 21 | CalendarTodayIcon, 22 | DoneIcon, 23 | ErrorIcon, 24 | HelpIcon, 25 | InfoIcon, 26 | KeyboardArrowDownIcon, 27 | KeyboardArrowLeftIcon, 28 | KeyboardArrowRightIcon, 29 | NotificationsIcon, 30 | VisibilityIcon, 31 | VisibilityOffIcon, 32 | WarnIcon 33 | }; 34 | -------------------------------------------------------------------------------- /libs/ui/core/typography/typography.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from './typography'; 2 | import { Meta } from '@storybook/react/types-6-0'; 3 | 4 | export default { 5 | title: 'Components/Typography', 6 | component: Typography, 7 | parameters: { 8 | layout: 'padded' 9 | } 10 | } as Meta; 11 | 12 | export const List = () => ( 13 | <> 14 | H1. Heading 1 15 | H2. Heading 2 16 | H3. Heading 3 17 | 18 | Label text 19 | 20 | 21 | Body text 22 | 23 | 24 | ); 25 | 26 | export const Colors = () => ( 27 | <> 28 | 29 | H3. Heading 3 - red 300 30 | 31 | 32 | H3. Heading 3 - blue 600 33 | 34 | 35 | H3. Heading 3 - primary-main 36 | 37 | 38 | ); 39 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | database: 5 | image: postgres 6 | restart: always 7 | environment: 8 | POSTGRES_PASSWORD: 1234 9 | ports: 10 | - "5432:5432" 11 | networks: 12 | - default 13 | 14 | service-users: 15 | build: 16 | context: . 17 | dockerfile: Dockerfile 18 | target: service-users 19 | image: service-users 20 | container_name: service-users 21 | depends_on: 22 | - database 23 | environment: 24 | HOST: 0.0.0.0 25 | DATABASE_HOST: database 26 | ports: 27 | - 20001:20001 28 | networks: 29 | - default 30 | 31 | 32 | service-auth: 33 | build: 34 | context: . 35 | dockerfile: Dockerfile 36 | target: service-auth 37 | image: service-auth 38 | container_name: service-auth 39 | depends_on: 40 | - database 41 | - service-users 42 | environment: 43 | HOST: 0.0.0.0 44 | DATABASE_HOST: database 45 | SERVICE_USERS_HOST: service-users 46 | ports: 47 | - 20002:20002 48 | networks: 49 | - default 50 | 51 | networks: 52 | default: 53 | -------------------------------------------------------------------------------- /libs/ui/core/popover/usePopover.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useState } from 'react'; 2 | 3 | export interface UsePopoverParams { 4 | id: string; 5 | disabled?: boolean; 6 | } 7 | 8 | export type PopoverState = ReturnType; 9 | 10 | export function usePopover({ id, disabled }: UsePopoverParams) { 11 | const [anchorNode, setAnchorNode] = useState(null); 12 | const handleClick = useCallback(e => setAnchorNode(e.currentTarget), []); 13 | const close = useCallback(() => setAnchorNode(null), []); 14 | const open = useCallback((target: HTMLElement) => setAnchorNode(target), []); 15 | 16 | return useMemo( 17 | () => ({ 18 | expanded: !!anchorNode, 19 | close, 20 | open, 21 | triggerProps: { 22 | 'aria-controls': anchorNode ? id : void 0, 23 | onClick: disabled ? void 0 : handleClick, 24 | 'aria-haspopup': true 25 | }, 26 | popoverProps: { 27 | id, 28 | anchorNode, 29 | open: !!anchorNode && !disabled, 30 | onClose: close 31 | } 32 | }), 33 | [id, close, handleClick, disabled, anchorNode] 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /libs/ui/stories/Icons/IconPreviewButton.tsx: -------------------------------------------------------------------------------- 1 | import { SvgIconProps } from '../../core/svg-icon'; 2 | import clsx from 'clsx'; 3 | import { ComponentType, memo } from 'react'; 4 | 5 | export interface IconPreviewButtonProps { 6 | Icon: ComponentType; 7 | name: string; 8 | onClick?(): void; 9 | } 10 | 11 | export const IconPreviewButton = memo(({ Icon, name, onClick }: IconPreviewButtonProps) => ( 12 |
13 | 23 |
27 | {name} 28 |
29 |
30 | )); 31 | 32 | IconPreviewButton.displayName = 'IconPreviewButton'; 33 | -------------------------------------------------------------------------------- /apps/todo-web/features/add-task/ui/Input.tsx: -------------------------------------------------------------------------------- 1 | import { useEventCallback } from '@libs/ui/hooks'; 2 | import { useEvent } from 'effector-react'; 3 | import { ChangeEvent, KeyboardEvent, memo, useState } from 'react'; 4 | import { TaskModel } from '@/entities/task'; 5 | 6 | export const AddTaskInput = memo(() => { 7 | const [value, setValue] = useState(''); 8 | const addTask = useEvent(TaskModel.addTask); 9 | 10 | const handleChange = useEventCallback((e: ChangeEvent) => 11 | setValue(e.target.value) 12 | ); 13 | const handleKeyDown = useEventCallback((e: KeyboardEvent) => { 14 | if (e.code === 'Enter') { 15 | setValue(''); 16 | addTask({ 17 | label: value 18 | }); 19 | } 20 | }); 21 | 22 | return ( 23 | 31 | ); 32 | }); 33 | 34 | AddTaskInput.displayName = 'AddTaskInput'; 35 | -------------------------------------------------------------------------------- /libs/ui/hooks/keyboard/types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type KeyboardHotKey = 4 | | KeyboardKey 5 | | `${KeyboardModifierKey}+${KeyboardKey}` 6 | | `${KeyboardModifierKey}+${KeyboardModifierKey}+${KeyboardKey}` 7 | | `${KeyboardModifierKey}+${KeyboardModifierKey}+${KeyboardModifierKey}+${KeyboardKey}`; 8 | 9 | export type HotkeyEvent = React.KeyboardEvent | KeyboardEvent; 10 | export type HotkeyHandlerFn = (event: E) => any; 11 | export type HotkeyHandlerEntry = [KeyboardHotKey, HotkeyHandlerFn]; 12 | 13 | export type KeyboardWhitespaceKey = 'Enter' | 'Tab'; 14 | export type KeyboardNavigationKey = 15 | | 'ArrowUp' 16 | | 'ArrowDown' 17 | | 'ArrowLeft' 18 | | 'ArrowRight' 19 | | 'End' 20 | | 'Home' 21 | | 'PageUp' 22 | | 'PageDown'; 23 | export type KeyboardModifierKey = 'Alt' | 'Shift' | 'Ctrl' | 'Meta' | 'Mod'; 24 | export type KeyboardEditingKey = 'Backspace' | 'Delete' | 'Insert' | 'Clear'; 25 | export type KeyboardUIKey = 'ContextMenu' | 'Escape' | `Key${string}` | string; 26 | 27 | export type KeyboardKey = 28 | | KeyboardWhitespaceKey 29 | | KeyboardNavigationKey 30 | | KeyboardEditingKey 31 | | KeyboardUIKey; 32 | -------------------------------------------------------------------------------- /libs/ui/lib/refs.ts: -------------------------------------------------------------------------------- 1 | import { Nil } from '../types'; 2 | import { isValidElement, MutableRefObject, ReactNode, Ref, RefCallback } from 'react'; 3 | 4 | /** 5 | * WARNING: Be sure to only call this inside a callback that is passed as a ref. 6 | * Otherwise, make sure to cleanup the previous {ref} if it changes. 7 | * 8 | * Useful if you want to expose the ref of an inner component to the public API 9 | * while still using it inside the component. 10 | * @param ref A ref callback or ref object. If anything falsy, this is a no-op. 11 | * @param value Ref value 12 | */ 13 | export default function setRef( 14 | ref: MutableRefObject | ((instance: T | null) => void) | Nil, 15 | value: T | null 16 | ): void { 17 | if (typeof ref === 'function') { 18 | ref(value); 19 | } else if (ref) { 20 | ref.current = value; 21 | } 22 | } 23 | 24 | export const getChildrenRef = (children: ReactNode): Ref | null => 25 | isValidElement(children) ? (children as any).ref ?? null : null; 26 | 27 | export const mergeTernaryRefs = 28 | (left: Ref | Nil, right: Ref | Nil): RefCallback => 29 | (refValue: T) => { 30 | setRef(left, refValue); 31 | setRef(right, refValue); 32 | }; 33 | -------------------------------------------------------------------------------- /libs/ui/types/elements.ts: -------------------------------------------------------------------------------- 1 | import { MergeProps, PropsOf } from './common'; 2 | import { 3 | AriaAttributes, 4 | AriaRole, 5 | CSSProperties, 6 | ElementType, 7 | KeyboardEvent, 8 | MouseEvent 9 | } from 'react'; 10 | 11 | export interface HTMLElementCoreProps { 12 | id?: string; 13 | role?: AriaRole; 14 | style?: CSSProperties; 15 | className?: string; 16 | } 17 | 18 | export interface HTMLElementProps extends HTMLElementCoreProps, AriaAttributes { 19 | title?: string; 20 | } 21 | 22 | export interface TouchableElementProps { 23 | disabled?: boolean; 24 | 25 | onClick?(e: MouseEvent): void; 26 | 27 | onMouseUp?(e: MouseEvent): void; 28 | 29 | onMouseDown?(e: MouseEvent): void; 30 | } 31 | 32 | export interface KeyboardProps { 33 | onKeyPress?(e: KeyboardEvent): void; 34 | onKeyDown?(e: KeyboardEvent): void; 35 | onKeyUp?(e: KeyboardEvent): void; 36 | } 37 | 38 | /** 39 | * Overrides 40 | */ 41 | 42 | export interface OverrideAsProps { 43 | as?: C; 44 | } 45 | 46 | export type PolymorphicProps = MergeProps< 47 | Props & OverrideAsProps, 48 | PropsOf 49 | >; 50 | -------------------------------------------------------------------------------- /libs/ui/core/input/html.tsx: -------------------------------------------------------------------------------- 1 | import { UISize } from '../../types'; 2 | import { InputSharedProps } from './types'; 3 | import clsx from 'clsx'; 4 | import { ForwardedRef, forwardRef } from 'react'; 5 | 6 | export interface HtmlInputProps extends Omit { 7 | size?: Extract; 8 | } 9 | 10 | /** 11 | * Basic styled HTML input without any additional controls. 12 | * Probably useless in real world because it cannot be customized 13 | */ 14 | export const HtmlInput = forwardRef(function HtmlInput( 15 | { className, disabled, size = 'md', ...props }: HtmlInputProps, 16 | ref: ForwardedRef 17 | ) { 18 | return ( 19 | 33 | ); 34 | }); 35 | 36 | HtmlInput.displayName = 'HtmlInput'; 37 | 38 | const sizeClasses = { 39 | sm: 'h-8', 40 | md: 'h-10' 41 | }; 42 | -------------------------------------------------------------------------------- /libs/ui/core/modal/portal.tsx: -------------------------------------------------------------------------------- 1 | import { useChildrenForkRef, useUniversalLayoutEffect } from '../../hooks'; 2 | import setRef from '../../lib/refs'; 3 | import { cloneElement, forwardRef, ReactElement, useState } from 'react'; 4 | import { createPortal } from 'react-dom'; 5 | 6 | export interface PortalProps { 7 | disablePortal?: boolean; 8 | targetNode?: HTMLElement | null; 9 | children: ReactElement; 10 | } 11 | 12 | export const Portal = forwardRef(function Portal( 13 | { children, targetNode, disablePortal }: PortalProps, 14 | ref 15 | ) { 16 | const [mountNode, setMountNode] = useState(null); 17 | const forkRef = useChildrenForkRef(children, ref); 18 | 19 | useUniversalLayoutEffect(() => { 20 | if (!disablePortal) { 21 | setMountNode(targetNode ?? document.body); 22 | } 23 | }, [targetNode, disablePortal]); 24 | 25 | useUniversalLayoutEffect(() => { 26 | if (mountNode && !disablePortal) { 27 | setRef(ref, mountNode); 28 | return () => setRef(ref, null); 29 | } 30 | return void 0; 31 | }, [mountNode, disablePortal]); 32 | 33 | if (disablePortal) { 34 | return cloneElement(children, { 35 | ref: forkRef 36 | }); 37 | } 38 | return mountNode ? createPortal(children, mountNode) : mountNode; 39 | }); 40 | -------------------------------------------------------------------------------- /libs/ui/core/dialog/dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Paper } from '../card'; 2 | import { Modal } from '../modal'; 3 | import { Transition } from '../transition/transition'; 4 | import clsx from 'clsx'; 5 | import { ReactNode } from 'react'; 6 | 7 | export interface DialogProps { 8 | open?: boolean; 9 | onClose?(): void; 10 | className?: string; 11 | fullWidth?: boolean; 12 | maxWidth?: 'sm' | 'md' | 'lg'; 13 | children: NonNullable; 14 | } 15 | 16 | export function Dialog({ 17 | className, 18 | children, 19 | open, 20 | onClose, 21 | maxWidth = 'md', 22 | fullWidth 23 | }: DialogProps) { 24 | return ( 25 | 26 | 27 | 36 | {children} 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | const maxWidthClass = { 44 | sm: 'max-w-screen-sm', 45 | md: 'max-w-screen-md', 46 | lg: 'max-w-screen-lg' 47 | }; 48 | -------------------------------------------------------------------------------- /libs/ui/core/input/input-box.tsx: -------------------------------------------------------------------------------- 1 | import { PropsOf, UISize } from '../../types'; 2 | import { InputStatesProps } from './types'; 3 | import clsx from 'clsx'; 4 | import { ForwardedRef, forwardRef, ReactNode } from 'react'; 5 | 6 | export interface InputBoxProps extends InputStatesProps, PropsOf<'div'> { 7 | children: NonNullable; 8 | tabIndex?: number; 9 | size?: Extract; 10 | } 11 | 12 | export const InputBox = forwardRef( 13 | ( 14 | { 15 | children, 16 | className, 17 | invalid, 18 | loading, 19 | disabled, 20 | focused, 21 | size = 'md', 22 | role = 'textbox', 23 | ...props 24 | }: InputBoxProps, 25 | ref: ForwardedRef 26 | ) => ( 27 |
40 | {children} 41 |
42 | ) 43 | ); 44 | 45 | InputBox.displayName = 'InputBox'; 46 | 47 | const sizeClasses = { 48 | sm: 'h-8', 49 | md: 'h-10' 50 | }; 51 | -------------------------------------------------------------------------------- /libs/ui/core/icon-button/icon-button.tsx: -------------------------------------------------------------------------------- 1 | import { getColorVariable } from '../../lib/theme'; 2 | import { PropsOf, UIMainColorName, UISize } from '../../types'; 3 | import clsx from 'clsx'; 4 | import { ReactNode } from 'react'; 5 | 6 | export interface IconButtonProps extends PropsOf<'button'> { 7 | children: NonNullable; 8 | color?: UIMainColorName; 9 | size?: UISize; 10 | } 11 | 12 | export function IconButton({ 13 | children, 14 | className, 15 | disabled, 16 | color, 17 | style, 18 | size = 'md', 19 | ...props 20 | }: IconButtonProps) { 21 | return ( 22 | 41 | ); 42 | } 43 | 44 | const sizeClasses: Record = { 45 | sm: 'w-size-sm h-size-sm text-md', 46 | md: 'w-size-md h-size-md text-2xl', 47 | lg: 'w-size-lg h-size-lg text-3xl' 48 | }; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "my-project", 4 | "version": "0.0.1", 5 | "devDependencies": { 6 | "@commitlint/cli": "16.2.3", 7 | "@commitlint/config-conventional": "16.2.1", 8 | "@types/jest": "^27.4.1", 9 | "@types/node": "^17.0.23", 10 | "@typescript-eslint/eslint-plugin": "^5.16.0", 11 | "@typescript-eslint/parser": "^5.16.0", 12 | "eslint": "^8.12.0", 13 | "eslint-config-airbnb": "^19.0.4", 14 | "eslint-config-prettier": "^8.5.0", 15 | "eslint-import-resolver-typescript": "^2.7.0", 16 | "eslint-plugin-import": "^2.25.4", 17 | "eslint-plugin-prettier": "^4.0.0", 18 | "husky": "^7.0.4", 19 | "jest": "^27.5.1", 20 | "jest-circus": "^27.5.1", 21 | "lint-staged": "^12.3.7", 22 | "prettier": "^2.6.1", 23 | "ts-jest": "^27.1.4", 24 | "tslib": "^2.3.1", 25 | "typescript": "^4.6.3" 26 | }, 27 | "scripts": { 28 | "lint": "eslint {apps,libs}/**/*.{ts,tsx,js}", 29 | "preinstall": "node -e \"if(process.env.npm_execpath.indexOf('yarn') === -1) throw new Error('You must use Yarn to install, not NPM')\"", 30 | "postinstall": "husky install" 31 | }, 32 | "engines": { 33 | "node": ">14", 34 | "yarn": ">3" 35 | }, 36 | "workspaces": { 37 | "packages": [ 38 | "apps/*", 39 | "libs/*" 40 | ] 41 | }, 42 | "packageManager": "yarn@3.2.0" 43 | } 44 | -------------------------------------------------------------------------------- /apps/todo-web/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@libs/ui/core/button'; 2 | import { Spaced } from '@libs/ui/core/grid'; 3 | import { Popover } from '@libs/ui/core/popover'; 4 | import { AddIcon, KeyboardArrowLeftIcon } from '@libs/ui/icons'; 5 | import { useState } from 'react'; 6 | 7 | export default function IndexPage() { 8 | const [anchorEl, setAnchorEl] = useState(null); 9 | 10 | return ( 11 |
12 | 13 | setAnchorEl(null)}> 14 | Popover example 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 29 | 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /libs/ui/.svgrrc.js: -------------------------------------------------------------------------------- 1 | const { basename, extname } = require('path'); 2 | 3 | module.exports = { 4 | icon: true, 5 | svgo: true, 6 | prettier: true, 7 | typescript: true, 8 | expandProps: false, 9 | jsxRuntime: 'automatic', 10 | indexTemplate(files) { 11 | const items = files.map(file => { 12 | const fileName = basename(file, extname(file)); 13 | /** 14 | * Foo -> FooIcon 15 | * 1Bar -> Svg1BarIcon 16 | * MyIcon -> MyIconIcon 17 | */ 18 | const exportName = `${/^\d/.test(fileName) ? `Svg${fileName}` : fileName}Icon`; 19 | 20 | return { fileName, exportName }; 21 | }); 22 | 23 | const imports = items.map( 24 | ({ fileName, exportName }) => `import ${exportName} from "./${fileName}";` 25 | ); 26 | const exports = items.map(({ exportName }) => exportName); 27 | 28 | return ` 29 | ${imports.join('\n')} 30 | 31 | export { 32 | ${exports.join(',\n')} 33 | }`; 34 | }, 35 | template({ imports, interfaces, componentName, props, jsx, exports }, { tpl }) { 36 | const viewBox = jsx.openingElement.attributes.find(atr => atr.name.name === 'viewBox'); 37 | 38 | return tpl` 39 | import { createSvgIcon } from '@libs/ui/core/svg-icon'; 40 | 41 | export default createSvgIcon( 42 | "${componentName}", 43 | "${viewBox.value.value}", 44 | ${jsx.children} 45 | ); 46 | `; 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /libs/ui/core/button/button.tsx: -------------------------------------------------------------------------------- 1 | import { getColorVariable } from '../../lib/theme'; 2 | import { PropsOf, UIMainColorName, UISize } from '../../types'; 3 | import clsx from 'clsx'; 4 | import { ReactNode } from 'react'; 5 | 6 | export interface ButtonProps extends PropsOf<'button'> { 7 | appearance?: ButtonAppearance; 8 | children: NonNullable; 9 | disabled?: boolean; 10 | size?: UISize; 11 | color?: UIMainColorName; 12 | } 13 | 14 | export type ButtonAppearance = 'contained' | 'outlined' | 'text'; 15 | 16 | export function Button({ 17 | children, 18 | appearance = 'contained', 19 | color = 'primary', 20 | disabled, 21 | size = 'md', 22 | className, 23 | style, 24 | ...props 25 | }: ButtonProps) { 26 | return ( 27 | 42 | ); 43 | } 44 | 45 | const sizeClasses = { 46 | sm: 'h-8 px-3 text-sm', 47 | md: 'h-10 px-4 text-base', 48 | lg: 'h-12 px-6 text-lg' 49 | }; 50 | const appearanceClasses = { 51 | contained: 'button-contained', 52 | outlined: 'button-outlined', 53 | text: 'button-text' 54 | }; 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ts-monorepo-template 2 | 3 | Monorepo template for fullstack projects 4 | 5 | ## Setup 6 | 7 | 1. Install [Yarn](https://yarnpkg.com/getting-started/install) 8 | and update it to latest version (probably, optional) - `yarn set version latest` 9 | 2. (`optional`) Run `yarn install` for building platform-specific dependencies 10 | 3. Run any package command ex. `yarn workspace @my-project/client-web dev` 11 | 12 | ## Tools 13 | 14 | - [Taskfile](https://taskfile.dev/#/) - Task manager 15 | - [Hygen](https://www.hygen.io/) - Code generation tool 16 | 17 | ## Scripts 18 | 19 | See 20 | 21 | ## Tech/framework used 22 | 23 | ### FrontEnd 24 | 25 | - [NextJS](https://nextjs.org/) 26 | - [Tailwind](https://tailwindcss.com/) 27 | 28 | ### BackEnd 29 | 30 | - `TODO` [NestJS](https://nestjs.com/) 31 | 32 | ### Tests 33 | 34 | - [Jest](https://jestjs.io/) for unit tests. 35 | 36 | Other test types not supported yet. 37 | 38 | ### Package manager - [Yarn v3](https://yarnpkg.com) 39 | 40 | - [Workspaces](https://yarnpkg.com/features/workspaces) for great code organization 41 | 42 | ### Code style 43 | 44 | - [Prettier](https://prettier.io/) 45 | - [ESLint](https://eslint.org/) with prettier and typescript support 46 | 47 | ### Other 48 | 49 | - [Husky](https://github.com/typicode/husky) for custom local git hooks 50 | - [lint-staged](https://github.com/okonet/lint-staged) for advanced pre-commit hook configuration 51 | -------------------------------------------------------------------------------- /libs/config/jest/base.js: -------------------------------------------------------------------------------- 1 | const GLOB_TS_FILE = '**/*.ts?(x)'; 2 | const GLOB_DTS_FILE = '**/*.d.ts'; 3 | const GLOB_TEST_FILE = '**/*.(spec|test).ts?(x)'; 4 | 5 | const ROOT_TEST_MATCH = `/${GLOB_TEST_FILE}`; 6 | const ROOT_COVERAGE_PATHS = [ 7 | `/${GLOB_TS_FILE}`, 8 | `!/${GLOB_DTS_FILE}`, 9 | `!/${GLOB_TEST_FILE}` 10 | ]; 11 | 12 | const createPathCollectCoverageFrom = path => [ 13 | `/${path}/${GLOB_TS_FILE}`, 14 | `!/${path}/${GLOB_DTS_FILE}`, 15 | `!/${path}/${GLOB_TEST_FILE}` 16 | ]; 17 | const createPathTestMatch = path => `/${path}/${GLOB_TEST_FILE}`; 18 | 19 | module.exports = { 20 | createJestBaseConfig({ rootFolders, tsconfig }) { 21 | return { 22 | preset: 'ts-jest', 23 | rootDir: '.', 24 | testRunner: 'jest-circus/runner', 25 | testEnvironment: 'node', 26 | cacheDirectory: '/node_modules/.cache/jest', 27 | collectCoverageFrom: rootFolders 28 | ? rootFolders.flatMap(createPathCollectCoverageFrom) 29 | : [ROOT_COVERAGE_PATHS], 30 | testMatch: rootFolders ? rootFolders.map(createPathTestMatch) : [ROOT_TEST_MATCH], 31 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], 32 | globals: { 33 | 'ts-jest': { 34 | compiler: 'typescript', 35 | tsconfig, 36 | isolatedModules: true 37 | } 38 | } 39 | }; 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /libs/ui/core/transition/transition.tsx: -------------------------------------------------------------------------------- 1 | import { useChildrenForkRef } from '../../hooks'; 2 | import styles from './transition.module.css'; 3 | import { TransitionOptions } from './types'; 4 | import { useTransition } from './useTransition'; 5 | import clsx from 'clsx'; 6 | import { cloneElement, ForwardedRef, forwardRef, ReactElement } from 'react'; 7 | 8 | export interface TransitionProps extends TransitionOptions { 9 | children: ReactElement; 10 | type?: TransitionVariant; 11 | } 12 | 13 | export type TransitionVariant = 'Grow' | 'Fade' | 'Collapse'; 14 | 15 | export const Transition = forwardRef(function Transition( 16 | { 17 | children, 18 | type, 19 | open, 20 | duration, 21 | exitDuration, 22 | 23 | onExit, 24 | onEnter, 25 | onExited, 26 | onEntered, 27 | onExiting, 28 | onEntering, 29 | 30 | ...rest 31 | }: TransitionProps, 32 | ref: ForwardedRef 33 | ) { 34 | const { status } = useTransition({ 35 | open, 36 | duration, 37 | exitDuration, 38 | 39 | onExit, 40 | onEnter, 41 | onExited, 42 | onEntered, 43 | onExiting, 44 | onEntering 45 | }); 46 | 47 | return cloneElement(children, { 48 | className: clsx( 49 | (children.props as any).className, 50 | type && styles[type], 51 | (rest as any).className 52 | ), 53 | 'data-ui-transition-status': status, 54 | ref: useChildrenForkRef(children, ref), 55 | ...(rest as any) 56 | }); 57 | }); 58 | 59 | Transition.displayName = 'Transition'; 60 | -------------------------------------------------------------------------------- /libs/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@libs/ui", 3 | "version": "0.0.1", 4 | "packageManager": "yarn@3.0.0", 5 | "scripts": { 6 | "cleanup": "yarn dlx rimraf icons node_modules/.cache", 7 | "storybook": "start-storybook -p 6006", 8 | "build": "yarn cleanup && yarn build:icons", 9 | "build:icons": "svgr --out-dir icons --ignore-existing -- static/icons", 10 | "build:storybook": "build-storybook" 11 | }, 12 | "dependencies": { 13 | "@fontsource/inter": "^4.5.7", 14 | "clsx": "^1.1.1", 15 | "dayjs": "^1.11.0", 16 | "effector": "^22.2.0", 17 | "effector-react": "^22.0.6", 18 | "patronum": "^1.8.2", 19 | "react": "^17.0.2" 20 | }, 21 | "devDependencies": { 22 | "@babel/core": "^7.17.8", 23 | "@libs/config": "*", 24 | "@libs/utils": "*", 25 | "@storybook/addon-actions": "^6.4.19", 26 | "@storybook/addon-docs": "^6.4.19", 27 | "@storybook/addon-essentials": "^6.4.19", 28 | "@storybook/addon-interactions": "^6.4.19", 29 | "@storybook/addon-links": "^6.4.19", 30 | "@storybook/addon-postcss": "^2.0.0", 31 | "@storybook/react": "^6.4.19", 32 | "@storybook/testing-library": "^0.0.9", 33 | "@svgr/cli": "^6.2.1", 34 | "@types/jest": "^27.4.1", 35 | "@types/react": "^17.0.43", 36 | "@types/tailwindcss": "^3.0.9", 37 | "autoprefixer": "^10.4.4", 38 | "postcss": "^8.4.12", 39 | "tailwindcss": "^3.0.23" 40 | }, 41 | "peerDependencies": { 42 | "autoprefixer": "^10.4.2", 43 | "postcss": "^8.4.8", 44 | "tailwindcss": "^3.0.23" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /libs/ui/core/transition/useTransition.ts: -------------------------------------------------------------------------------- 1 | import { TransitionDuration } from './constants'; 2 | import { TransitionOptions, TransitionStatus } from './types'; 3 | import { noop } from '@libs/utils/core'; 4 | import { useEffect, useState } from 'react'; 5 | 6 | export function useTransition({ 7 | open, 8 | duration = TransitionDuration.enteringScreen, 9 | exitDuration = TransitionDuration.leavingScreen, 10 | 11 | onExit = noop, 12 | onEnter = noop, 13 | onExited = noop, 14 | onEntered = noop, 15 | onExiting = noop, 16 | onEntering = noop 17 | }: TransitionOptions) { 18 | const [status, setStatus] = useState('exited'); 19 | 20 | useEffect(() => { 21 | const handleStart = open ? onEnter : onExit; 22 | const handleEnd = open ? onEntered : onExited; 23 | const phaseDuration = open ? duration : exitDuration; 24 | 25 | if (phaseDuration === 0) { 26 | handleStart(); 27 | handleEnd(); 28 | setStatus(open ? 'entered' : 'exited'); 29 | return; 30 | } else { 31 | handleStart(); 32 | const startTimeout = setTimeout(() => { 33 | open ? onEntering() : onExiting(); 34 | setStatus(open ? 'entering' : 'exiting'); 35 | }, 10); 36 | const endTimeout = setTimeout(() => { 37 | clearTimeout(startTimeout); 38 | handleEnd(); 39 | setStatus(open ? 'entered' : 'exited'); 40 | }, phaseDuration); 41 | 42 | return () => { 43 | clearTimeout(startTimeout); 44 | clearTimeout(endTimeout); 45 | }; 46 | } 47 | }, [open]); 48 | 49 | return { 50 | status 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /libs/ui/core/typography/typography.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLElementType, PolymorphicProps, PropsOf } from '../../types'; 2 | import clsx from 'clsx'; 3 | import { ElementType, ReactNode } from 'react'; 4 | 5 | export interface TypographyProps { 6 | className?: string; 7 | children: NonNullable; 8 | type?: TypographyType; 9 | } 10 | 11 | export type TypographyType = 'label' | 'body' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; 12 | 13 | export type TypographyPolymorphicProps = PolymorphicProps< 14 | C, 15 | TypographyProps 16 | >; 17 | 18 | export function Typography({ 19 | children, 20 | className, 21 | type = 'body', 22 | as: Component = ElementsTypes[type] as C, 23 | ...props 24 | }: TypographyPolymorphicProps) { 25 | return ( 26 | )}> 27 | {children} 28 | 29 | ); 30 | } 31 | 32 | const ElementsTypes: Record = { 33 | label: 'label', 34 | body: 'span', 35 | h1: 'h1', 36 | h2: 'h2', 37 | h3: 'h3', 38 | h4: 'h4', 39 | h5: 'h5', 40 | h6: 'h6' 41 | }; 42 | 43 | const ClassNames: Record = { 44 | label: 'text-base font-medium leading-normal tracking-[0.4px] text-ui-label mb-2', 45 | body: 'text-sm font-normal text-black', 46 | h1: 'text-9xl font-extralight text-black mb-12', 47 | h2: 'text-8xl font-extralight text-black mb-8', 48 | h3: 'text-7xl font-normal text-black mb-6', 49 | h4: 'text-5xl font-normal text-black mb-4', 50 | h5: 'text-3xl font-normal text-black mb-4', 51 | h6: 'text-xl font-bold text-gray-700 mb-4' 52 | }; 53 | -------------------------------------------------------------------------------- /libs/ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const withOpacityValue = 2 | name => 3 | ({ opacityValue }) => 4 | opacityValue === void 0 || +opacityValue === 1 5 | ? `var(--palette-${name})` 6 | : `rgb(var(--palette-rgb-${name}) / ${opacityValue})`; 7 | 8 | /** @type {import('tailwindcss/tailwind-config').TailwindConfig} */ 9 | module.exports = { 10 | content: ['./**/*.tsx', './**/*.mdx'], 11 | theme: { 12 | extend: { 13 | fontFamily: { 14 | sans: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";' 15 | }, 16 | boxShadow: { 17 | 'el-sm': 18 | '0px 3px 1px -2px rgba(0, 0, 0, 0.20), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12)', 19 | 'el-md': 20 | '0px 2px 4px -1px rgba(0, 0, 0, 0.20), 0px 4px 5px 0px rgba(0, 0, 0, 0.14), 0px 1px 10px 0px rgba(0, 0, 0, 0.12)', 21 | 'el-lg': 22 | '0px 5px 5px -3px rgba(0, 0, 0, 0.2), 0px 8px 10px 1px rgba(0, 0, 0, 0.14), 0px 3px 14px 2px rgba(0, 0, 0, 0.12)' 23 | }, 24 | colors: { 25 | ui: { 26 | label: '#61718D' 27 | }, 28 | primary: { 29 | light: 'var(--palette-primary-light)', 30 | main: withOpacityValue('primary-main'), 31 | dark: 'var(--palette-primary-dark)' 32 | }, 33 | secondary: { 34 | light: 'var(--palette-secondary-light)', 35 | main: withOpacityValue('secondary-main'), 36 | dark: 'var(--palette-secondary-dark)' 37 | } 38 | }, 39 | spacing: { 40 | 'size-sm': '32px', 41 | 'size-md': '40px', 42 | 'size-lg': '48px' 43 | } 44 | } 45 | }, 46 | plugins: [] 47 | }; 48 | -------------------------------------------------------------------------------- /libs/ui/stories/Icons/icons-preview.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, DialogContent } from '../../core/dialog'; 2 | import { SvgIconProps } from '../../core/svg-icon'; 3 | import { IconPreviewButton } from './IconPreviewButton'; 4 | import { ComponentType, memo, useState } from 'react'; 5 | 6 | export interface IconsPreviewProps { 7 | components: Record>; 8 | } 9 | 10 | export const IconsPreview = memo(({ components }: IconsPreviewProps) => { 11 | const [open, setOpen] = useState(false); 12 | const [SelectedIcon, setSelectedIcon] = useState | null>(null); 13 | 14 | console.log({ 15 | SelectedIcon, 16 | components 17 | }); 18 | return ( 19 | <> 20 | setOpen(false)} fullWidth> 21 | 22 |
23 |
24 | {SelectedIcon && } 25 |
26 |
27 |
28 | {`import { ${SelectedIcon?.displayName} } from "@libs/ui/icons";`} 29 |
30 |
31 |
32 |
33 |
34 | {Object.entries(components).map(([name, Icon]) => ( 35 | { 40 | setSelectedIcon(Icon); 41 | setOpen(true); 42 | }} 43 | /> 44 | ))} 45 | 46 | ); 47 | }); 48 | 49 | IconsPreview.displayName = 'IconsPreview'; 50 | -------------------------------------------------------------------------------- /libs/ui/theme/styles/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .absolute-center { 7 | @apply absolute left-1/2 top-1/2; 8 | transform: translate(-50%, -50%); 9 | } 10 | 11 | .ring, 12 | .focus-visible-ring:focus-visible { 13 | @apply outline outline-2 outline-offset-2 outline-primary-main; 14 | } 15 | 16 | .input-box { 17 | @apply transition border border-gray-300 rounded-md outline-none outline-offset-0 overflow-hidden; 18 | } 19 | 20 | .input-box:not([aria-disabled="true"]) { 21 | @apply hover:border-gray-500 focus:border-primary-main focus:outline-1 focus:outline-primary-main; 22 | } 23 | 24 | .input-box[aria-invalid="true"] { 25 | @apply border-red-800 outline-red-800; 26 | } 27 | 28 | .button-base, 29 | .button { 30 | --button-color: currentColor; 31 | 32 | @apply box-border overflow-hidden flex items-center justify-between; 33 | } 34 | 35 | .button { 36 | @apply transition relative rounded-md px-4 py-2 box-border overflow-hidden; 37 | @apply after:absolute after:inset-0 after:z-0 after:opacity-0 after:content-[""] after:transition-opacity after:bg-current; 38 | } 39 | 40 | .button:not([aria-disabled="true"]) { 41 | @apply active:scale-95 hover:after:opacity-10 focus:after:opacity-25; 42 | } 43 | 44 | .button[aria-disabled="true"] { 45 | @apply bg-gray-300 text-gray-600 cursor-wait; 46 | } 47 | 48 | .button-outlined, 49 | .button-text { 50 | color: var(--button-color); 51 | } 52 | 53 | .button-contained { 54 | @apply text-white; 55 | background-color: var(--button-color); 56 | } 57 | 58 | .button-outlined { 59 | @apply border border-current; 60 | } 61 | 62 | .button-contained:not([aria-disabled="true"]) { 63 | @apply shadow-el-sm hover:shadow-el-md active:shadow-el-lg active:scale-95; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /libs/ui/theme/styles/variables.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --palette-rgb-primary-main: 47 128 237; 3 | --palette-rgb-secondary-main: 39 174 96; 4 | 5 | --palette-primary-light: #579acd; 6 | --palette-primary-main: #2f80ed; 7 | --palette-primary-dark: #1e55a3; 8 | 9 | --palette-secondary-light: #5cc689; 10 | --palette-secondary-main: #27ae60; 11 | --palette-secondary-dark: #269a5b; 12 | 13 | --palette-success-light: #5cc689; 14 | --palette-success-main: #27ae60; 15 | --palette-success-dark: #269a5b; 16 | 17 | --palette-warning-light: #fcd34d; 18 | --palette-warning-main: #f59e0b; 19 | --palette-warning-dark: #b45309; 20 | 21 | --palette-error-light: #eb5757; 22 | --palette-error-main: #d20000; 23 | --palette-error-dark: #a81414; 24 | 25 | /* Durations for Transition components */ 26 | --transition-duration-entering-screen: 225ms; 27 | --transition-duration-leaving-screen: 195ms; 28 | --transition-duration-standard: 300ms; 29 | 30 | /* This is the most common easing curve. */ 31 | --transition-easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); 32 | /* Objects enter the screen at full velocity from off-screen and 33 | slowly decelerate to a resting point. */ 34 | --transition-easing-ease-out: cubic-bezier(0.4, 0, 0.2, 1); 35 | /* Objects leave the screen at full velocity. They do not decelerate when off-screen. */ 36 | --transition-easing-ease-in: cubic-bezier(0.4, 0, 0.2, 1); 37 | /* The sharp curve is used by objects that may return to the screen at any time. */ 38 | --transition-easing-sharp: cubic-bezier(0.4, 0, 0.2, 1); 39 | 40 | --spacing-px: 6px; 41 | --spacing-2xs: calc(0.5 * var(--spacing-px)); 42 | --spacing-xs: calc(1 * var(--spacing-px)); 43 | --spacing-sm: calc(2 * var(--spacing-px)); 44 | --spacing-md: calc(3 * var(--spacing-px)); 45 | --spacing-lg: calc(4 * var(--spacing-px)); 46 | --spacing-xl: calc(5 * var(--spacing-px)); 47 | } 48 | -------------------------------------------------------------------------------- /libs/ui/core/transition/transition.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '../button'; 2 | import { Paper } from '../card'; 3 | import { Spaced } from '../grid'; 4 | import { Typography } from '../typography'; 5 | import { Collapse } from './Collapse'; 6 | import { Transition } from './transition'; 7 | import { Meta } from '@storybook/react/types-6-0'; 8 | import { useState } from 'react'; 9 | 10 | export default { 11 | title: 'Components/Transition' 12 | } as Meta; 13 | 14 | export const Grow = () => { 15 | const [open, setOpen] = useState([]); 16 | const toggleBy = (value: string) => () => 17 | setOpen(prev => (prev.includes(value) ? prev.filter(p => p !== value) : prev.concat(value))); 18 | const isOpen = (value: string) => open.includes(value); 19 | 20 | return ( 21 |
22 |
23 | 24 | 25 | 26 | 27 | 28 |
29 |
30 |
31 | Grow 32 | 33 | 34 | 35 |
36 |
37 | Fade 38 | 39 | 40 | 41 |
42 |
43 | Collapse 44 | 45 | 46 | 47 |
48 |
49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /libs/ui/core/svg-icon/svg-icon.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { 3 | ComponentType, 4 | createElement, 5 | ForwardedRef, 6 | forwardRef, 7 | ReactNode, 8 | Ref, 9 | SVGProps 10 | } from 'react'; 11 | 12 | export interface SvgIconProps 13 | extends Pick, 'viewBox' | 'className' | 'children' | 'style'> { 14 | titleAccess?: string; 15 | htmlColor?: string; 16 | fontSize?: string | number; 17 | svgRef?: Ref; 18 | color?: string; // TODO 19 | } 20 | 21 | /** 22 | * Component creator for internal usage 23 | * @param rawName 24 | * @param viewBox 25 | * @param children 26 | */ 27 | export function createSvgIcon( 28 | rawName: string, 29 | viewBox: string, 30 | ...children: ReactNode[] 31 | ): ComponentType { 32 | const IconComponent = forwardRef((props: SvgIconProps, ref: ForwardedRef) => 33 | createElement(SvgIcon, { ...props, viewBox, svgRef: ref }, ...children) 34 | ); 35 | 36 | IconComponent.displayName = `${rawName.replace(/(^Svg|Icon$)/g, '')}Icon`; 37 | return IconComponent; 38 | } 39 | 40 | export function SvgIcon({ 41 | titleAccess, 42 | color, 43 | fontSize, 44 | htmlColor, 45 | className, 46 | children, 47 | style, 48 | viewBox, 49 | svgRef 50 | }: SvgIconProps) { 51 | return ( 52 | 69 | {children} 70 | {titleAccess && {titleAccess}} 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /libs/ui/core/transition/Collapse.tsx: -------------------------------------------------------------------------------- 1 | import { useForkCallback } from '../../hooks'; 2 | import { TransitionDuration } from './constants'; 3 | import { Transition, TransitionProps } from './transition'; 4 | import styles from './transition.module.css'; 5 | import { ForwardedRef, forwardRef, useRef } from 'react'; 6 | 7 | export const Collapse = forwardRef(function Collapse( 8 | { children, open, onEnter, onExit, onExiting, ...props }: TransitionProps, 9 | ref: ForwardedRef 10 | ) { 11 | const rootRef = useRef(null); 12 | const wrapperRef = useRef(null); 13 | 14 | const getSize = () => wrapperRef.current?.clientHeight ?? 0; 15 | const handleEnter = () => { 16 | rootRef.current!.style.setProperty(CSS_HEIGHT_VAR, `${getSize()}px`); 17 | rootRef.current!.style.removeProperty(CSS_HEIGHT_EXIT_VAR); 18 | }; 19 | const handleExit = () => { 20 | rootRef.current!.style.setProperty(CSS_HEIGHT_EXIT_VAR, `${getSize()}px`); 21 | }; 22 | const handleExiting = () => { 23 | requestAnimationFrame(() => { 24 | rootRef.current!.style.removeProperty(CSS_HEIGHT_EXIT_VAR); 25 | }); 26 | }; 27 | 28 | return ( 29 | 40 |
41 |
42 |
{children}
43 |
44 |
45 |
46 | ); 47 | }); 48 | 49 | Collapse.displayName = 'Collapse'; 50 | 51 | const CSS_HEIGHT_VAR = '--height'; 52 | const CSS_HEIGHT_EXIT_VAR = '--height-exit'; 53 | -------------------------------------------------------------------------------- /libs/config/next/index.js: -------------------------------------------------------------------------------- 1 | const withBundleAnalyzer = require('@next/bundle-analyzer'); 2 | const withPlugins = require('next-compose-plugins'); 3 | const withTranspileModules = require('next-transpile-modules'); 4 | const log = require('next/dist/build/output/log'); 5 | 6 | const createEnvFn = 7 | (fn, { defaultFallback = null }) => 8 | (name, { fallback = defaultFallback } = {}) => { 9 | const value = process.env[name]; 10 | 11 | return value !== void 0 ? fn(value) : fallback; 12 | }; 13 | 14 | module.exports = { 15 | log, 16 | env: { 17 | string: createEnvFn(p => p, { defaultFallback: '' }), 18 | number: createEnvFn(p => +p, { defaultFallback: null }), 19 | bool: createEnvFn(p => p === 'true', { defaultFallback: false }) 20 | }, 21 | createNextConfig( 22 | { cwd, workspaceDependencies = [], analyzer: { enabled, detailed } = {}, logSettings }, 23 | configuration = {} 24 | ) { 25 | log.info( 26 | 'Next application settings:\n', 27 | JSON.stringify( 28 | { 29 | analyzer: { 30 | enabled, 31 | detailed 32 | }, 33 | workspaceDependencies, 34 | ...logSettings 35 | }, 36 | null, 37 | 2 38 | ) 39 | ); 40 | return withPlugins( 41 | [ 42 | withBundleAnalyzer({ 43 | enabled 44 | }), 45 | withTranspileModules(workspaceDependencies) 46 | ], 47 | { 48 | swcMinify: false, 49 | reactStrictMode: true, 50 | ...configuration, 51 | experimental: { 52 | externalHelpers: true, 53 | esmExternals: true, 54 | externalDir: false, 55 | ...(configuration.experimental ?? {}) 56 | }, 57 | webpack(config, options) { 58 | if (enabled && detailed) { 59 | config.optimization.concatenateModules = false; 60 | config.optimization.moduleIds = 'named'; 61 | config.optimization.chunkIds = 'named'; 62 | } 63 | return configuration.webpack ? configuration.webpack(config, options) : config; 64 | } 65 | } 66 | ); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /libs/ui/.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | stories: ['../**/*.stories.mdx', '../**/*.stories.@(js|jsx|ts|tsx)'], 5 | addons: [ 6 | '@storybook/addon-links', 7 | '@storybook/addon-essentials', 8 | '@storybook/addon-interactions', 9 | [ 10 | '@storybook/addon-postcss', 11 | { 12 | options: { 13 | postcssLoaderOptions: { 14 | postcssOptions: { 15 | plugins: [require.resolve('tailwindcss')] 16 | }, 17 | implementation: require('postcss') 18 | } 19 | } 20 | } 21 | ] 22 | ], 23 | framework: '@storybook/react', 24 | typescript: { 25 | check: false, 26 | checkOptions: {}, 27 | reactDocgen: 'react-docgen-typescript', 28 | reactDocgenTypescriptOptions: { 29 | shouldExtractLiteralValuesFromEnum: true, 30 | propFilter: prop => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true) 31 | } 32 | }, 33 | webpackFinal(config = {}, options = {}) { 34 | const cssRule = config.module.rules.find(_ => _ && _.test && _.test.toString() === css_regex); 35 | 36 | return { 37 | ...config, 38 | module: { 39 | ...config.module, 40 | rules: [ 41 | ...config.module.rules.filter(_ => _ && _.test && _.test.toString() !== css_regex), 42 | { 43 | ...cssRule, 44 | exclude: /\.module\.css$/ 45 | }, 46 | { 47 | ...cssRule, 48 | test: /\.module\.css$/, 49 | use: cssRule.use.map(_ => { 50 | if (_ && _.loader && _.loader.match(/[\/\\]css-loader/g)) { 51 | return { 52 | ..._, 53 | options: { 54 | ..._.options, 55 | modules: { 56 | localIdentName: '[name]__[local]__[hash:base64:5]' 57 | } 58 | } 59 | }; 60 | } 61 | 62 | return _; 63 | }) 64 | } 65 | ] 66 | } 67 | }; 68 | } 69 | }; 70 | 71 | const css_regex = '/\\.css$/'; 72 | -------------------------------------------------------------------------------- /libs/ui/core/popover/lib.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PopoverElementPosition, 3 | PopoverOrigin, 4 | PopoverPositionType, 5 | PopoverRect, 6 | PopoverRectOffset 7 | } from './types'; 8 | 9 | const getRectOffset = (rectValue: number, value: PopoverPositionType | number) => { 10 | if (typeof value === 'number') { 11 | return value; 12 | } else if (value === 'center') { 13 | return rectValue / 2; 14 | } else if (value === 'end') { 15 | return rectValue; 16 | } 17 | return 0; 18 | }; 19 | 20 | export const getPopoverRectOffset = ( 21 | rect: PopoverRect, 22 | position: PopoverOrigin 23 | ): PopoverRectOffset => ({ 24 | top: getRectOffset(rect.height, position.vertical), 25 | left: getRectOffset(rect.width, position.horizontal) 26 | }); 27 | 28 | export const getTransformOriginStyleByRect = ({ top, left }: PopoverRectOffset) => { 29 | return [left, top].map(n => `${n}px`).join(' '); 30 | }; 31 | 32 | export function adjustPosition( 33 | offset: PopoverRectOffset, 34 | position: PopoverElementPosition, 35 | restrictions: PopoverRect, 36 | threshold: number 37 | ) { 38 | // Window thresholds taking required margin into account 39 | // Check if the vertical axis needs shifting 40 | adjustPositionAxis(offset, position, threshold, restrictions.height, 'top', 'bottom'); 41 | // Check if the horizontal axis needs shifting 42 | adjustPositionAxis(offset, position, threshold, restrictions.width, 'left', 'right'); 43 | } 44 | 45 | export function adjustPositionAxis( 46 | offset: PopoverRectOffset, 47 | position: PopoverElementPosition, 48 | threshold: number, 49 | restriction: number, 50 | startProperty: keyof PopoverRectOffset, 51 | endProperty: keyof PopoverElementPosition 52 | ) { 53 | const restrictionThreshold = restriction - threshold; 54 | 55 | if (position[startProperty] < threshold) { 56 | const diff = position[startProperty] - threshold; 57 | 58 | position[startProperty] -= diff; 59 | offset[startProperty] += diff; 60 | } else if (position[endProperty] > restrictionThreshold) { 61 | const diff = position[endProperty] - restrictionThreshold; 62 | 63 | position[startProperty] -= diff; 64 | offset[startProperty] += diff; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /libs/ui/hooks/keyboard/lib.ts: -------------------------------------------------------------------------------- 1 | import { HotkeyEvent, HotkeyHandlerEntry, KeyboardHotKey, KeyboardModifierKey } from './types'; 2 | import { assoc } from '@libs/utils/core/object/assoc'; 3 | 4 | export function getHotkeyHandler(items: Array>) { 5 | const matchedHandler = items.map(([hotkey, handler]) => [matchHotkey(hotkey), handler]); 6 | 7 | return (event: HotkeyEvent) => { 8 | for (const [matches, handler] of matchedHandler) { 9 | if (matches(event as E)) { 10 | event.preventDefault(); 11 | handler(event as E); 12 | } 13 | } 14 | }; 15 | } 16 | 17 | export function matchHotkey(hotkey: KeyboardHotKey) { 18 | const info = parseHotkey(hotkey); 19 | 20 | return (event: HotkeyEvent) => validateHotkeyEvent(info, event); 21 | } 22 | 23 | export function parseHotkey(hotkey: KeyboardHotKey): ParsedInfo { 24 | const keys = hotkey 25 | .toLowerCase() 26 | .split('+') 27 | .map(part => part.trim()); 28 | const modifiersInfo = modifiers.reduce( 29 | (acc, key) => assoc(key, keys.includes(key), acc), 30 | {} as ParsedModifiersInfo 31 | ); 32 | 33 | return { 34 | ...modifiersInfo, 35 | key: keys.find(key => !modifiers.includes(key as ParsedModifier))! 36 | }; 37 | } 38 | 39 | export function isNonInputKeyboardEvent(event: KeyboardEvent) { 40 | if (event.target instanceof HTMLElement) { 41 | return !['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName); 42 | } 43 | return true; 44 | } 45 | 46 | function validateHotkeyEvent( 47 | { alt, key, mod, meta, shift, ctrl }: ParsedInfo, 48 | { altKey, ctrlKey, metaKey, shiftKey, key: pressedKey, code }: HotkeyEvent 49 | ): boolean { 50 | // Check modifiers pressed and key exists 51 | if ( 52 | !key || 53 | alt !== altKey || 54 | // "mod" means "ctrl OR meta" 55 | (mod && !ctrlKey && !metaKey) || 56 | (ctrl && !ctrlKey) || 57 | (meta && !metaKey) || 58 | (shift && !shiftKey) 59 | ) { 60 | return false; 61 | } 62 | 63 | return pressedKey.toLowerCase() === key || code.replace('Key', '').toLowerCase() === key; 64 | } 65 | 66 | const modifiers: ParsedModifier[] = ['alt', 'ctrl', 'meta', 'shift', 'mod']; 67 | 68 | type ParsedModifier = Lowercase; 69 | type ParsedModifiersInfo = Record; 70 | interface ParsedInfo extends ParsedModifiersInfo { 71 | key: string; 72 | } 73 | -------------------------------------------------------------------------------- /libs/ui/core/transition/transition.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | Fade 3 | */ 4 | .Fade { 5 | opacity: 0; 6 | transition: opacity var(--transition-duration-entering-screen) var(--transition-easing-ease-in-out) 0ms; 7 | } 8 | 9 | .Fade[data-ui-transition-status="entering"], 10 | .Fade[data-ui-transition-status="entered"] { 11 | opacity: 1; 12 | } 13 | 14 | .Fade[data-ui-transition-status="exiting"] { 15 | transition: opacity var(--transition-duration-leaving-screen) var(--transition-easing-ease-in-out) 0ms; 16 | } 17 | 18 | /** 19 | Grow 20 | */ 21 | 22 | .Grow { 23 | --duration: calc(var(--transition-duration-entering-screen) * 1.75); 24 | 25 | opacity: 0; 26 | transform: scale(0.75, 0.5625); 27 | transition: 28 | opacity var(--duration) var(--transition-easing-ease-in-out) 0ms, 29 | transform calc(var(--duration) * 0.666) var(--transition-easing-ease-in-out) 0ms; 30 | } 31 | 32 | .Grow[data-ui-transition-status="entering"] { 33 | opacity: 1; 34 | transform: scale(1, 1); 35 | } 36 | 37 | .Grow[data-ui-transition-status="entered"] { 38 | opacity: 1; 39 | transform: none; 40 | } 41 | 42 | .Grow[data-ui-transition-status="exiting"] { 43 | --duration: calc(var(--transition-duration-leaving-screen) * 1.75); 44 | 45 | transition: 46 | opacity var(--duration) var(--transition-easing-ease-in-out) 0ms, 47 | transform calc(var(--duration) * 0.666) var(--transition-easing-ease-in-out) calc(var(--duration) * 0.333); 48 | } 49 | 50 | /** 51 | Collapse 52 | */ 53 | 54 | .Collapse { 55 | --height: auto; 56 | --height-exit: 0; 57 | 58 | min-height: 0; 59 | height: auto; 60 | overflow: hidden; 61 | transition: height var(--transition-duration-standard) var(--transition-easing-ease-in-out) 0ms; 62 | } 63 | 64 | .Collapse[data-ui-transition-status="entering"] { 65 | height: var(--height); 66 | } 67 | 68 | .Collapse[data-ui-transition-status="entered"] { 69 | overflow: visible; 70 | } 71 | 72 | .Collapse[data-ui-transition-status="entered"] { 73 | height: auto; 74 | } 75 | 76 | .Collapse[data-ui-transition-status="exiting"] { 77 | height: var(--height-exit); 78 | } 79 | 80 | .Collapse[data-ui-transition-status="exited"] { 81 | height: var(--height-exit); 82 | visibility: hidden; 83 | } 84 | 85 | .Collapse > .Wrapper { 86 | display: flex; 87 | width: 100%; 88 | } 89 | 90 | .Collapse > .Wrapper > .Inner { 91 | width: 100%; 92 | } 93 | 94 | /** 95 | Shared 96 | */ 97 | 98 | .Fade[data-ui-transition-status="exited"], 99 | .Grow[data-ui-transition-status="exited"] { 100 | visibility: hidden; 101 | } 102 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | 'prettier', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:prettier/recommended', 7 | 'plugin:import/errors' 8 | ], 9 | parserOptions: { 10 | ecmaVersion: 2019, 11 | sourceType: 'module' 12 | }, 13 | rules: { 14 | 'import/order': [ 15 | 'error', 16 | { 17 | groups: [ 18 | 'builtin', // Built-in types are first 19 | ['sibling', 'parent'], // Then sibling and parent types. They can be mingled together 20 | 'index', // Then the index file 21 | 'external' 22 | ], 23 | 'newlines-between': 'never', 24 | alphabetize: { 25 | order: 'asc' /* sort in ascending order. Options: ['ignore', 'asc', 'desc'] */, 26 | caseInsensitive: true /* ignore case. Options: [true, false] */ 27 | } 28 | } 29 | ], 30 | // OK: MyType[], Array 31 | // Error: Array, (Foo | Bar)[] 32 | '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], 33 | // Allows instead of > 34 | '@typescript-eslint/ban-types': [ 35 | 'error', 36 | { 37 | types: { 38 | '{}': false 39 | } 40 | } 41 | ], 42 | /** 43 | * Problem - Map has + get 44 | * @example 45 | * const myMap = new Map(); 46 | * 47 | * if (myMap.has('foo') { 48 | * console.log(myMap.get('foo')!.value); 49 | * } 50 | */ 51 | '@typescript-eslint/no-non-null-assertion': 'off', 52 | /** 53 | * Allow unused arguments 54 | * @example 55 | * class MyModel { 56 | * @Field(type => String) 57 | * myField: string; 58 | * } 59 | */ 60 | '@typescript-eslint/no-unused-vars': ['error', { args: 'none', varsIgnorePattern: '^_' }], 61 | '@typescript-eslint/no-explicit-any': 'off' 62 | }, 63 | overrides: [ 64 | { 65 | files: ['*.js'], 66 | rules: { 67 | /** 68 | * Allow require('foo') for js files 69 | */ 70 | '@typescript-eslint/no-var-requires': 'off' 71 | } 72 | } 73 | ], 74 | settings: { 75 | 'import/parsers': { 76 | '@typescript-eslint/parser': ['.ts', '.tsx'] 77 | }, 78 | 'import/resolver': { 79 | typescript: { 80 | // always try to resolve types under `@types` directory even it doesn't contain any source code, like `@types/unist` 81 | alwaysTryTypes: true, 82 | project: ['tsconfig.json', 'apps/*/tsconfig.json', 'libs/*/tsconfig.json'] 83 | } 84 | } 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_IMAGE=node:15.6.0-alpine 2 | 3 | FROM ${NODE_IMAGE} as workspace 4 | 5 | WORKDIR /home/workspace 6 | 7 | COPY .yarn/ .yarn/ 8 | COPY .yarnrc.yml yarn.lock .pnp.js package.json ./ 9 | 10 | COPY tsconfig.node.json tsconfig.json tsconfig.common.nest.json babel.config.js ./ 11 | 12 | # TODO Make template/script for copying all package.json files 13 | 14 | COPY apps/client-web/package.json packages/client-web/package.json 15 | COPY apps/graphql/package.json packages/graphql/package.json 16 | COPY apps/icons/package.json packages/icons/package.json 17 | COPY apps/service-auth/package.json packages/service-auth/package.json 18 | COPY apps/service-shared/package.json packages/service-shared/package.json 19 | COPY apps/service-users/package.json packages/service-users/package.json 20 | COPY apps/shared/package.json packages/shared/package.json 21 | COPY apps/storybook/package.json packages/storybook/package.json 22 | COPY apps/uikit-web/package.json packages/uikit-web/package.json 23 | 24 | RUN yarn install --immutable --immutable-cache --inline-builds --skip-builds 25 | 26 | # === 27 | 28 | FROM workspace as service-users__artifacts 29 | 30 | COPY apps/service-users packages/service-users 31 | COPY apps/service-shared packages/service-shared 32 | 33 | RUN yarn workspace @my-project/service-users build 34 | 35 | # === 36 | 37 | FROM workspace as service-auth__artifacts 38 | 39 | COPY apps/service-auth packages/service-auth 40 | COPY apps/service-users packages/service-users 41 | COPY apps/service-shared packages/service-shared 42 | 43 | RUN yarn workspace @my-project/service-auth build 44 | 45 | # === 46 | 47 | FROM workspace as client-web__artifacts 48 | 49 | COPY apps/client-web packages/client-web 50 | COPY apps/uikit-web packages/uikit-web 51 | COPY apps/shared packages/shared 52 | COPY apps/icons packages/icons 53 | 54 | RUN yarn workspace @my-project/client-web build 55 | 56 | # === 57 | 58 | FROM ${NODE_IMAGE} as service-users 59 | 60 | WORKDIR /home/workspace 61 | 62 | COPY --from=workspace /home/workspace/ ./ 63 | COPY --from=service-users__artifacts /home/workspace/packages/service-users/dist ./packages/service-users/dist 64 | COPY --from=service-users__artifacts /home/workspace/packages/service-users/.env.example ./packages/service-users/.env.example 65 | 66 | CMD yarn workspace @my-project/service-users start 67 | 68 | # === 69 | 70 | FROM ${NODE_IMAGE} as service-auth 71 | 72 | WORKDIR /home/workspace 73 | 74 | COPY --from=workspace /home/workspace/ ./ 75 | COPY --from=service-auth__artifacts /home/workspace/packages/service-auth/dist ./packages/service-auth/dist 76 | COPY --from=service-auth__artifacts /home/workspace/packages/service-auth/.env.example ./packages/service-auth/.env.example 77 | 78 | CMD yarn workspace @my-project/service-auth start 79 | 80 | # === 81 | 82 | FROM ${NODE_IMAGE} as client-web 83 | 84 | ENV PORT=80 85 | 86 | COPY --from=client-web__artifacts /home/workspace/.yarn/ .yarn/ 87 | COPY --from=client-web__artifacts \ 88 | /home/workspace/.yarnrc.yml \ 89 | /home/workspace/yarn.lock \ 90 | /home/workspace/.pnp.js \ 91 | /home/workspace/package.json \ 92 | ./ 93 | 94 | COPY --from=client-web__artifacts /home/workspace/packages/client-web/package.json ./packages/client-web/package.json 95 | COPY --from=client-web__artifacts /home/workspace/packages/client-web/.next /home/workspace/packages/client-web/.next 96 | 97 | CMD yarn workspace @my-project/client-web start -p ${PORT} 98 | -------------------------------------------------------------------------------- /.yarn/build-state.yml: -------------------------------------------------------------------------------- 1 | # Warning: This file is automatically generated. Removing it is fine, but will 2 | # cause all your builds to become invalidated. 3 | 4 | # @apollo/protobufjs@npm:1.2.2 5 | "306a5b4ea3152d381e4220728e69b099a5c5e4b803ee06b19cbf6db116692f89599a18c18639bcaf39d8dc785b15aeff125e9d4220a8e5e1aed9fccab1fc836f": 6 | 2c8b00f81f5bb0cf86733a2bd12549bb594280a7e40779638edf8916906448205a17307178b3a085a7447bbee57ece952b4d1edfca07e9d1899b567505ac9f27 7 | 8 | # @nestjs/core@virtual:09167069eff601b307003523d5336813184947c15c977dd78e17a6a0dcd418d06eabf48bdd285fc01ccb6a5ed5f8d950f1d3d97a34a5a763a6b182412d1f446b#npm:8.0.2 9 | "384b4e3b9fa8c06c0bb504a3e1cd19fe2d1fe40fbbf6a7f78a2ec76f2f93dc7ff294c0fdac8989f1c9b95c7760775cecf83b6314e22e4bd84234fe4864004a23": 10 | ada66166b6a08df89419e032b819fb06204c278176236465ecbc4d1c56229cd5c3929b525f03e82ffc7fafe309d5d5c91418babbef76d72667f1ff14f56d145c 11 | 12 | # @nestjs/core@virtual:0e329136daed6d2ad6ed613ed9b0e9ebc37e1d8672d2a108683b48125e74156ceb881b8abb2c26a9b59e306be4c46db70e1e75460d7ff4f7c58640b49c45c379#npm:8.0.2 13 | "666979a6b2d4964d88d669ce70a27e1b106c9616b09c1e046bad98466727fbb6f30edab0d3c59eb67582800f2a4d936df9b484effe771ff8bd365d29f29715b9": 14 | f7311e56dce09011aa5df8958472cb71c4a0a7e25d7f14b5c39468cf2e5e0c2a1de02143fe1d4035f02c07e33ecb7ff9467cf37218198796300977ac13910322 15 | 16 | # @nestjs/core@virtual:b1499ca2bbbe32a86124169f4a61fba1ec3135487a5c2138a34ce93e9fd052997f2e276b652a0b82650c81edf2270960a517ddbef56cb6fbb15757c958dc8bb6#npm:8.0.2 17 | "696c1d15f38535ba9e99df1796230318c73d488f7f1016acba21dc6568fe36d2f9367a3fc40fd78b9ea7776fb5d651d110608080a635f8bdf3877b2472c1aa65": 18 | 929cb06f6d4d50da4b8f7caec2d9e9ef6278abb0970aabe16c068413b4801c80b9d59867cbb3ff65fb8d2ae14d750cbff746cd6163ef63beb4bfe4119fc8730a 19 | 20 | # @nestjs/core@virtual:f798150c3d6aba3b4bbcba4933dd0bd6e4e6145eee03bbb258be07f50e2d079b4061aa5eb3371492b3685a1fc3b8ad06fb86cfb79d847967ed01a8612d39a39f#npm:8.0.2 21 | "13f75b887b442230a21ddc409640430d1dd7d7dbf4c4ebb27e8fc75564eb7f4a48f2bcfff2c3da48e4dc1ad2996f487ea105af1717e6494ff8e74d5fae8121a5": 22 | 1dba99c82f9a50587c7db93d49be2ef0ebb139193776e7dcf21b4cd6d887ef80fdd947e2f8db8fc5f2f1fe14207974e480a9f3ea65648cb7ce56145a35d55cd4 23 | 24 | # core-js-pure@npm:3.15.2 25 | "2b9d54bed09edbafda0a9ce9f0deda44bf0efa20a7b5f95fe23989dc8c819b47060dc585fe847db1ac1bd37446f744ace40b4f278c3dd220a77dbead039008ef": 26 | c39c54e17f61eb52900855bdf53a90595baf386a458a87ea0f513cc7676b0c5dafe26f3ee1470c5069fdb1f73b2cda9f6d0e008c8295177fe2cbaaee70cf50f0 27 | 28 | # core-js@npm:3.15.2 29 | "6825052e106ce6ce8698952435c5ffbf482aa0c3cb5ecc901d7a0c7e5d39bd3e7df34bb42d53780c9a05ed0f299a233b9d54c689f2f5489bdc46ee55393184f2": 30 | 7fdca1043ec7555fbd8922390174018fdd7bd89f8d084df07a4c2d4bd5856203bb36ac606bcd9f8f6e8e4485e94cb6c4bd182e6c68f79312d6723511334dbf8b 31 | 32 | # fsevents@patch:fsevents@npm%3A1.2.13#builtin::version=1.2.13&hash=11e9ea 33 | "268c0b888ddfb611722b2ffd60f6a12729aed7f4e910e41b46d22ec5d652b177b3680e939f37b6278ca5903c81ec55105f93c7fc0944e125761166786225180b": 34 | b6b68a14b0613e080d4787e2c45e8e51ff2bbd484c69c81742e49eb2a46ddc193b01a08f6da927b3102abee440337442ac8e288b3212ddcabd3adde166d714e2 35 | 36 | # my-project@workspace:. 37 | "d9f8fd649a6bad2b2522c1dc82d0bf4d35a6315f955089c3bf9630d5b510fe28624bd29d391306da6bbfc3ec999e11972e7581f2b9f870eda59adaa3b0a1bc00": 38 | 622faa5b44a7a46f62ac0702bbb264cb51b3f11555cc4fee33d2f7e8df99cc39d74b72f23fa0a452e8ff37cefbc8e0f280649a5c71d033fd64904d2d69eb9715 39 | -------------------------------------------------------------------------------- /libs/ui/core/modal/modal-manager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getOwnerDocument, 3 | getOwnerWindow, 4 | getPaddingRight, 5 | getScrollbarSize, 6 | isOverflowing, 7 | setStyle, 8 | StyleDescriptor 9 | } from '../../lib/dom'; 10 | 11 | export interface ModalDescriptor { 12 | value: T | null; 13 | } 14 | 15 | export abstract class ModalManager { 16 | protected items: Array> = []; 17 | private mounted = false; 18 | 19 | isLast(item: ModalDescriptor) { 20 | return this.items.length > 0 && this.items.indexOf(item) === this.items.length - 1; 21 | } 22 | 23 | add(item: ModalDescriptor) { 24 | if (this.items.includes(item)) return; 25 | this.items.push(item); 26 | } 27 | 28 | mount() { 29 | if (this.mounted || this.items.length === 0) return; 30 | this.mounted = true; 31 | this.onMount(); 32 | } 33 | 34 | remove(item: ModalDescriptor) { 35 | if (!this.items.includes(item)) return; 36 | this.items.splice(this.items.indexOf(item), 1); 37 | if (this.items.length === 0) { 38 | this.mounted = false; 39 | this.onUnmount(); 40 | } 41 | } 42 | 43 | protected abstract onMount(): void; 44 | protected abstract onUnmount(): void; 45 | } 46 | 47 | export class BrowserModalManager extends ModalManager { 48 | private stylesToRestore: StyleDescriptor[] = []; 49 | 50 | protected onMount(): void { 51 | const currentDocument = getOwnerDocument(this.items[0].value); 52 | const currentBody = currentDocument.body; 53 | 54 | if (isOverflowing(currentBody)) { 55 | const scrollbarSize = getScrollbarSize(currentDocument); 56 | const addRightPaddingStyle = (target: HTMLElement) => { 57 | this.stylesToRestore.push({ 58 | property: 'padding-right', 59 | element: target, 60 | value: target.style.paddingRight 61 | }); 62 | 63 | target.style.paddingRight = `${getPaddingRight(target) + scrollbarSize}px`; 64 | }; 65 | 66 | addRightPaddingStyle(currentBody); 67 | /** 68 | * Preventing jumps of fixed-elements on overflow change 69 | */ 70 | for (const fixedElement of Array.from( 71 | currentBody.querySelectorAll('[data-ui-fixed-element]') 72 | )) { 73 | addRightPaddingStyle(fixedElement as HTMLElement); 74 | } 75 | } 76 | // Improve Gatsby support 77 | // https://css-tricks.com/snippets/css/force-vertical-scrollbar/ 78 | const parent = currentBody.parentElement; 79 | const containerWindow = getOwnerWindow(currentDocument); 80 | const scrollContainer = 81 | parent?.nodeName === 'HTML' && containerWindow.getComputedStyle(parent).overflowY === 'scroll' 82 | ? parent 83 | : currentBody; 84 | // Block the scroll even if no scrollbar is visible to account for mobile keyboard screensize shrink. 85 | this.stylesToRestore.push( 86 | { 87 | value: scrollContainer.style.overflow, 88 | property: 'overflow', 89 | element: scrollContainer 90 | }, 91 | { 92 | value: scrollContainer.style.overflowX, 93 | property: 'overflow-x', 94 | element: scrollContainer 95 | }, 96 | { 97 | value: scrollContainer.style.overflowY, 98 | property: 'overflow-y', 99 | element: scrollContainer 100 | } 101 | ); 102 | 103 | scrollContainer.style.overflow = 'hidden'; 104 | } 105 | 106 | protected onUnmount(): void { 107 | this.stylesToRestore.forEach(setStyle); 108 | this.stylesToRestore = []; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /libs/ui/theme/styles/global-standalone.css: -------------------------------------------------------------------------------- 1 | /* 2 | Document 3 | ======== 4 | */ 5 | 6 | /** 7 | Use a better box model (opinionated). 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | } 15 | 16 | *:focus-visible { 17 | outline: none; 18 | } 19 | 20 | /** 21 | Use a more readable tab size (opinionated). 22 | */ 23 | 24 | html { 25 | -moz-tab-size: 4; 26 | tab-size: 4; 27 | } 28 | 29 | /** 30 | 1. Correct the line height in all browsers. 31 | 2. Prevent adjustments of font size after orientation changes in iOS. 32 | */ 33 | 34 | html { 35 | line-height: 1.15; /* 1 */ 36 | -webkit-text-size-adjust: 100%; /* 2 */ 37 | } 38 | 39 | /* 40 | Sections 41 | ======== 42 | */ 43 | 44 | /** 45 | Remove the margin in all browsers. 46 | */ 47 | 48 | body { 49 | margin: 0; 50 | padding: 0; 51 | } 52 | 53 | /* 54 | Custom 55 | ======== 56 | */ 57 | 58 | html { 59 | height: 100vh; 60 | -webkit-text-size-adjust: 100%; 61 | -webkit-font-smoothing: antialiased; 62 | -moz-osx-font-smoothing: grayscale; 63 | } 64 | 65 | body { 66 | position: relative; 67 | font-feature-settings: 'tnum'; 68 | font-variant: tabular-nums; 69 | overflow-x: hidden; 70 | } 71 | 72 | body, 73 | #__next { 74 | height: 100vh; 75 | } 76 | 77 | html, 78 | body, 79 | #__next { 80 | font-size: 14px; 81 | } 82 | 83 | /** 84 | Typography 85 | */ 86 | 87 | /** 88 | Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) 89 | */ 90 | 91 | body { 92 | font-family: 93 | Roboto, 94 | system-ui, 95 | -apple-system, /* Firefox supports this but not yet `system-ui` */ 'Segoe UI', 96 | Helvetica, 97 | Arial, 98 | sans-serif, 99 | 'Apple Color Emoji', 100 | 'Segoe UI Emoji'; 101 | } 102 | 103 | /** 104 | 1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) 105 | 2. Correct the odd 'em' font sizing in all browsers. 106 | */ 107 | 108 | code, 109 | kbd, 110 | samp, 111 | pre { 112 | font-family: ui-monospace, 113 | SFMono-Regular, 114 | Consolas, 115 | 'Liberation Mono', 116 | Menlo, 117 | monospace; /* 1 */ 118 | font-size: 1em; /* 2 */ 119 | } 120 | 121 | *, ::before, ::after { 122 | box-sizing: border-box; 123 | border-width: 0; 124 | border-style: solid; 125 | border-color: currentColor; 126 | } 127 | 128 | input::-ms-clear, 129 | input::-ms-reveal { 130 | display: none; 131 | } 132 | 133 | a { 134 | text-decoration: none; 135 | background-color: transparent; 136 | outline: none; 137 | cursor: pointer; 138 | transition: color 0.3s; 139 | -webkit-text-decoration-skip: objects; 140 | } 141 | 142 | a[disabled] { 143 | cursor: not-allowed; 144 | pointer-events: none; 145 | } 146 | 147 | button, input, optgroup, select, textarea { 148 | font-family: inherit; 149 | font-size: 100%; 150 | margin: 0; 151 | padding: 0; 152 | line-height: inherit; 153 | color: inherit; 154 | } 155 | 156 | button, select { 157 | text-transform: none; 158 | } 159 | 160 | button, [type='button'], [type='reset'], [type='submit'] { 161 | -webkit-appearance: button; 162 | } 163 | 164 | button, [role="button"] { 165 | cursor: pointer; 166 | } 167 | 168 | button { 169 | background-color: transparent; 170 | background-image: none; 171 | } 172 | 173 | ol, ul { 174 | list-style: none; 175 | margin: 0; 176 | padding: 0; 177 | } 178 | 179 | blockquote, 180 | dl, 181 | dd, 182 | h1, 183 | h2, 184 | h3, 185 | h4, 186 | h5, 187 | h6, 188 | hr, 189 | figure, 190 | p, 191 | pre { 192 | margin: 0; 193 | } 194 | -------------------------------------------------------------------------------- /libs/ui/core/modal/trap-focus.tsx: -------------------------------------------------------------------------------- 1 | import { useChildrenForkRef } from '../../hooks'; 2 | import { focusTabbable, getOwnerDocument, isElementContainsFocus } from '../../lib/dom'; 3 | import { cloneElement, ReactElement, useEffect, useRef } from 'react'; 4 | 5 | export interface TrapFocusProps { 6 | active: boolean; 7 | children: ReactElement; 8 | 9 | isEnabled?(): boolean; 10 | } 11 | 12 | export function TrapFocus({ children, active, isEnabled = T }: TrapFocusProps) { 13 | const rootRef = useRef(null); 14 | const sentinelEndRef = useRef(null); 15 | const sentinelStartRef = useRef(null); 16 | 17 | const childrenForkRef = useChildrenForkRef(children, rootRef); 18 | 19 | useEffect(() => { 20 | const element = rootRef.current; 21 | 22 | if (!element || !active || !isEnabled()) return; 23 | const timer = setTimeout(() => { 24 | if (isElementContainsFocus(element)) return; 25 | const autoFocus = element.querySelector('[data-ui-autofocus="true"]'); 26 | 27 | ((autoFocus as HTMLElement) ?? element).focus(); 28 | }, 50); 29 | 30 | return () => clearTimeout(timer); 31 | }, [active]); 32 | 33 | useEffect(() => { 34 | if (!active || !rootRef.current || !isEnabled()) return; 35 | let lastKeydown: KeyboardEvent | null = null; 36 | const rootDocument = getOwnerDocument(rootRef.current); 37 | 38 | const forceFocus = () => { 39 | if (!rootRef.current) return; 40 | if (isEnabled() && !isElementContainsFocus(rootRef.current)) { 41 | const preferChildrenTabbable = 42 | rootDocument.activeElement === sentinelEndRef.current || 43 | rootDocument.activeElement === sentinelStartRef.current; 44 | 45 | const shouldFocusLast = Boolean(lastKeydown?.shiftKey && lastKeydown?.key === 'Tab'); 46 | 47 | focusTabbable(rootRef.current!, preferChildrenTabbable, shouldFocusLast); 48 | } 49 | }; 50 | const handleKeyDown = (nativeEvent: KeyboardEvent) => { 51 | lastKeydown = nativeEvent; 52 | 53 | if (!isEnabled() || nativeEvent.key !== 'Tab') return; 54 | 55 | // Make sure the next tab starts from the right place. 56 | // doc.activeElement referes to the origin. 57 | if (rootDocument.activeElement === rootRef.current && nativeEvent.shiftKey) { 58 | sentinelEndRef.current?.focus(); 59 | } 60 | }; 61 | 62 | rootDocument.addEventListener('focusin', forceFocus); 63 | rootDocument.addEventListener('keydown', handleKeyDown, true); 64 | 65 | // With Edge, Safari and Firefox, no focus related events are fired when the focused area stops being a focused area. 66 | // e.g. https://bugzilla.mozilla.org/show_bug.cgi?id=559561. 67 | // Instead, we can look if the active element was restored on the BODY element. 68 | // 69 | // The whatwg spec defines how the browser should behave but does not explicitly mention any events: 70 | // https://html.spec.whatwg.org/multipage/interaction.html#focus-fixup-rule. 71 | const interval = setInterval(() => { 72 | if (rootDocument.activeElement?.tagName === 'BODY') { 73 | forceFocus(); 74 | } 75 | }, 50); 76 | 77 | return () => { 78 | clearInterval(interval); 79 | rootDocument.removeEventListener('focusin', forceFocus); 80 | rootDocument.removeEventListener('keydown', handleKeyDown, true); 81 | }; 82 | }, [active, isEnabled]); 83 | 84 | return ( 85 | <> 86 |
87 | 88 | {cloneElement(children, { ref: childrenForkRef })} 89 | 90 |
91 | 92 | ); 93 | } 94 | 95 | const T = () => true; 96 | -------------------------------------------------------------------------------- /libs/ui/lib/dom/tabbable.ts: -------------------------------------------------------------------------------- 1 | import { getOwnerDocument } from './shared'; 2 | 3 | export function isElementContainsFocus(target: HTMLElement | null) { 4 | if (!target) return false; 5 | const targetDocument = getOwnerDocument(target); 6 | const { activeElement } = targetDocument; 7 | 8 | return targetDocument.hasFocus() && (target === activeElement || target.contains(activeElement)); 9 | } 10 | 11 | export function focusTabbable( 12 | element: HTMLElement, 13 | preferChildren?: boolean, 14 | shouldFocusLastTabbable?: boolean 15 | ) { 16 | if (preferChildren) { 17 | const tabbable = getOrderedTabbableElements(element); 18 | 19 | if (tabbable.length > 0) { 20 | const elementToFocus = shouldFocusLastTabbable ? tabbable[tabbable.length - 1] : tabbable[0]; 21 | 22 | (elementToFocus as HTMLElement).focus(); 23 | return; 24 | } 25 | } 26 | requestAnimationFrame(() => element.focus()); 27 | } 28 | 29 | export function getOrderedTabbableElements(root: Element) { 30 | const elementsWithZeroTabIndex: Element[] = []; 31 | const elementsGroupedByTabIndex: Element[][] = []; 32 | 33 | root.querySelectorAll(TABBABLE_SELECTOR).forEach(element => { 34 | const nodeTabIndex = getElementTabIndex(element); 35 | 36 | if (nodeTabIndex < 0 || !isFocusableTabbableElement(element)) return; 37 | 38 | if (nodeTabIndex === 0) { 39 | elementsWithZeroTabIndex.push(element); 40 | } else if (elementsGroupedByTabIndex[nodeTabIndex]) { 41 | elementsGroupedByTabIndex[nodeTabIndex].push(element); 42 | } else { 43 | elementsGroupedByTabIndex[nodeTabIndex] = [element]; 44 | } 45 | }); 46 | 47 | return elementsGroupedByTabIndex.flat().concat(elementsWithZeroTabIndex); 48 | } 49 | 50 | export function isFocusableTabbableElement(element: Element) { 51 | if ((element as HTMLInputElement).disabled) return false; 52 | if (isInput(element)) { 53 | if (element.type === 'hidden') return false; 54 | if (isNamedRadio(element)) return isTappableRadio(element); 55 | return true; 56 | } 57 | return true; 58 | } 59 | 60 | export function getElementTabIndex(element: Element) { 61 | const tabIndexAttrValue = element.getAttribute('tabindex'); 62 | const tabIndexAsNumber = parseInt(tabIndexAttrValue!, 10); 63 | 64 | if (!Number.isNaN(tabIndexAsNumber)) return tabIndexAsNumber; 65 | 66 | // Browsers do not return `tabIndex` correctly for contentEditable nodes; 67 | // https://bugs.chromium.org/p/chromium/issues/detail?id=661108&q=contenteditable%20tabindex&can=2 68 | // so if they don't have a tabindex attribute specifically set, assume it's 0. 69 | // in Chrome,
,