├── .github └── workflows │ └── main.yml ├── .gitignore ├── README.md ├── TODO.md ├── declaration.d.ts ├── index.html ├── package-lock.json ├── package.json ├── src ├── App.tsx ├── app.css ├── assets │ └── favicon.ico ├── blocks │ ├── Helpers.docs.tsx │ ├── accordion.css │ ├── accordion.docs.tsx │ ├── accordion.tsx │ ├── avatar.css │ ├── avatar.docs.tsx │ ├── avatar.tsx │ ├── bar.css │ ├── bar.docs.tsx │ ├── bar.tsx │ ├── base.css │ ├── breadcrumbs.css │ ├── breadcrumbs.docs.tsx │ ├── breadcrumbs.tsx │ ├── button.css │ ├── button.docs.tsx │ ├── button.tsx │ ├── checkbox.css │ ├── checkbox.docs.tsx │ ├── checkbox.tsx │ ├── index.ts │ ├── menu.css │ ├── menu.docs.tsx │ ├── menu.tsx │ ├── message.css │ ├── message.docs.tsx │ ├── message.tsx │ ├── meter.css │ ├── meter.docs.tsx │ ├── meter.tsx │ ├── modal.css │ ├── modal.docs.tsx │ ├── modal.tsx │ ├── progress.css │ ├── progress.docs.tsx │ ├── progress.tsx │ ├── radio.css │ ├── radio.docs.tsx │ ├── radio.tsx │ ├── select.css │ ├── select.docs.tsx │ ├── select.tsx │ ├── spinner.css │ ├── spinner.docs.tsx │ ├── spinner.tsx │ ├── tabs.css │ ├── tabs.docs.tsx │ ├── tabs.tsx │ ├── tag.css │ ├── tag.docs.tsx │ ├── tag.tsx │ ├── textfield.css │ ├── textfield.docs.tsx │ ├── textfield.tsx │ ├── theme.docs.tsx │ ├── toast.css │ ├── toast.docs.tsx │ ├── toast.tsx │ ├── tools.ts │ ├── tooltip.css │ ├── tooltip.docs.tsx │ └── tooltip.tsx ├── index.css ├── index.tsx └── logo.svg ├── tsconfig.json ├── vite.config.lib.ts ├── vite.config.ts └── yarn.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | push: 9 | branches: [ main ] 10 | pull_request: 11 | branches: [ main ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | 23 | # Steps represent a sequence of tasks that will be executed as part of the job 24 | steps: 25 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 26 | - uses: actions/checkout@v2 27 | 28 | - name: install 29 | run: npm i 30 | 31 | - name: build 32 | run: npm run build 33 | 34 | - name: deploy pages 35 | uses: JamesIves/github-pages-deploy-action@4.1.4 36 | with: 37 | branch: gh-pages # The branch the action should deploy to. 38 | folder: dist # The folder the action should deploy. 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solid-Blocks 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | UI building blocks for [SolidJS](https://solidjs.com) 19 | 20 | ## Core concepts 21 | 22 | ### Valuable components instead of components without added value 23 | 24 | Wrapping elements like headers, text, or images in custom Components is just wasteful. Components will only be provided if they have added value over their native elements. The added value may be 25 | 26 | - user experience 27 | - accessibility 28 | - developer experience 29 | - performance 30 | 31 | If none of these advantages can be provided, it is preferable to use native HTML elements or SolidJS' abilities like Portal effectively. 32 | 33 | ### Components with style instead of styled components 34 | 35 | Directly using CSS is frowned upon nowadays, but not rightfully so. Well crafted CSS will easily outperform styled components. It should do so with 36 | 37 | - minimal bleeding (class prefix `sb-[component]`, CSS reset, basic styles, theme variables) 38 | - semantic class names, i.e. `.primary.sb-button` 39 | - careful consideration of a11y 40 | - works as much as possible in non-JS environments (SSR) 41 | - theme-able, dark mode, inline mode switch possible 42 | - TODO: responsive layout 43 | 44 | ### Usage 45 | 46 | ```shell 47 | yarn 48 | yarn dev 49 | ``` 50 | 51 | To use the components 52 | 53 | ```tsx 54 | import { Accordion, AccordionHeader } from "solid-blocks"; 55 | 56 | const MyApp = () => { 57 | return ( 58 | 59 | Accordion 60 |

Hidden

61 |
62 | ); 63 | }; 64 | ``` 65 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | Road to first official release: 4 | 5 | - Rewrite components that overuse DOM events 6 | - SSR/hydration support 7 | - Styles: 8 | - extend + improve theme 9 | - basic spacings + sizes 10 | - Components: 11 | - Icons / Logo 12 | - Round(Icon)Button 13 | - Custom DropDown 14 | -------------------------------------------------------------------------------- /declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg"; 2 | declare module "*.png"; 3 | declare module "*.jpg"; 4 | declare module "*.jpeg"; 5 | declare module "*.gif"; 6 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Solid App 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "solid-blocks", 3 | "version": "0.1.1", 4 | "description": "UI building blocks for SolidJS", 5 | "homepage": "https://atk.github.io/solid-blocks/", 6 | "author": { 7 | "name": "Alex Lohr", 8 | "email": "alex.lohr@logmein.com" 9 | }, 10 | "files": [ 11 | "dist" 12 | ], 13 | "main": "dist/solid-blocks.cjs.js", 14 | "module": "dist/solid-blocks.es.js", 15 | "types": "dist/types/index.d.ts", 16 | "exports": { 17 | ".": { 18 | "import": "./dist/solid-blocks.es.js", 19 | "require": "./dist/solid-blocks.cjs.js", 20 | "solid": "./dist/solid" 21 | } 22 | }, 23 | "sideEffects": false, 24 | "scripts": { 25 | "start": "vite", 26 | "dev": "vite", 27 | "typecheck": "tsc --noEmit", 28 | "build": "vite build; vite -c vite.config.lib.ts build; tsc", 29 | "prepare": "npm run build" 30 | }, 31 | "license": "MIT", 32 | "devDependencies": { 33 | "typescript": "^4.8.4", 34 | "vite": "^3.2.3", 35 | "vite-plugin-solid": "^2.4.0" 36 | }, 37 | "dependencies": { 38 | "solid-js": "^1.6.1" 39 | }, 40 | "peerDependencies": { 41 | "solid-js": "^1.6.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Component, Show } from "solid-js"; 2 | import { 3 | Bar, 4 | Checkbox, 5 | Tabs, 6 | Tab, 7 | TabContainer, 8 | Tooltip, 9 | useDarkMode, 10 | Button, 11 | TabList, 12 | } from "./blocks"; 13 | 14 | import "./app.css"; 15 | import { AccordionDocs } from "./blocks/accordion.docs"; 16 | import { AvatarDocs } from "./blocks/avatar.docs"; 17 | import { BarDocs } from "./blocks/bar.docs"; 18 | import { BreadcrumbsDocs } from "./blocks/breadcrumbs.docs"; 19 | import { ButtonDocs } from "./blocks/button.docs"; 20 | import { CheckboxDocs } from "./blocks/checkbox.docs"; 21 | import { MenuDocs } from "./blocks/menu.docs"; 22 | import { MessageDocs } from "./blocks/message.docs"; 23 | import { MeterDocs } from "./blocks/meter.docs"; 24 | import { ModalDocs } from "./blocks/modal.docs"; 25 | import { ProgressDocs } from "./blocks/progress.docs"; 26 | import { RadioDocs } from "./blocks/radio.docs"; 27 | import { SelectDocs } from "./blocks/select.docs"; 28 | import { SpinnerDocs } from "./blocks/spinner.docs"; 29 | import { TabsDocs } from "./blocks/tabs.docs"; 30 | import { TextfieldDocs } from "./blocks/textfield.docs"; 31 | import { ToastDocs } from "./blocks/toast.docs"; 32 | import { TooltipDocs } from "./blocks/tooltip.docs"; 33 | import { HelpersDocs } from "./blocks/Helpers.docs"; 34 | import { ThemeDocs } from "./blocks/theme.docs"; 35 | import { TagDocs } from "./blocks/tag.docs"; 36 | 37 | const App: Component = () => { 38 | const [darkMode, setDarkMode] = useDarkMode(); 39 | return ( 40 |
41 | 42 |
43 |

44 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | {" "} 64 | Solid Blocks 65 |

66 |
67 |
68 | 87 | 93 | 94 | {" Light Mode"} 95 | 96 | 97 |
98 |
99 | 100 | 101 | Documentation 102 | TODO 103 | Concepts 104 | 105 | 106 | 107 | 108 | 109 | ⇧ 110 | 111 | 112 | 113 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 |
    220 |
  • Rewrite components that overuse DOM events
  • 221 |
  • SSR support
  • 222 |
  • 223 | Styles 224 |
      225 |
    • extend + improve theme
    • 226 |
    • basic spacing, sizes and typography
    • 227 |
    228 |
  • 229 |
  • 230 | More components: 231 |
      232 |
    • Icons / Logo
    • 233 |
    • Round(Icon)Button
    • 234 |
    • Custom DropDown
    • 235 |
    236 |
  • 237 |
238 |
239 | 240 |

241 | Valuable components instead of components without added value 242 |

243 |

244 | Wrapping elements like headers, text, or images in custom Components 245 | is just wasteful. Components will only be provided if they have 246 | added value over their native elements. The added value may be 247 |

248 |
    249 |
  • user experience
  • 250 |
  • accessibility
  • 251 |
  • developer experience
  • 252 |
253 |

254 | If none of these advantages can be provided, it is preferable to use 255 | native HTML elements or SolidJS' abilities like Portal effectively. 256 |

257 |

Components with style instead of styled components

258 |

259 | Directly using CSS is frowned upon nowadays, but not rightfully so. 260 | Well crafted CSS will easily outperform styled components. It should 261 | do so with 262 |

263 |
    264 |
  • 265 | minimal bleeding (class prefix sb-[component], CSS 266 | reset, basic styles) 267 |
  • 268 |
  • 269 | semantic class names, i.e. .primary.sb-button 270 |
  • 271 |
  • careful consideration of a11y
  • 272 |
  • components styles work in non-JS environments (SSR)
  • 273 |
  • responsive layout
  • 274 |
  • theme-able, dark mode, inline mode switch possible
  • 275 |
276 |
277 |
278 |
279 | ); 280 | }; 281 | 282 | export default App; 283 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | #root { 6 | margin: 0; 7 | padding: 0; 8 | } 9 | body { 10 | font-size: calc(0.5em + 1.5vmin); 11 | line-height: 1.5em; 12 | } 13 | @media (max-width: 50em) and (max-height: 50em) { 14 | body { font-size: 1em; } 15 | } 16 | .app { 17 | padding: 1em 1em 3em 1em; 18 | height: 100%; 19 | } 20 | .top.sb-bar:after { 21 | content: ""; 22 | display: block; 23 | position: absolute; 24 | top: 100%; 25 | background: linear-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); 26 | height: 1em; 27 | width: 100%; 28 | } 29 | .dark-mode .top.sb-bar:after { 30 | background: linear-gradient(rgba(35, 35, 35, 1), rgba(35, 35, 35, 0)); 31 | } 32 | .top.sb-bar + * { 33 | margin-top: 1em; 34 | } 35 | .flex-row.flex-row { 36 | display: flex; 37 | flex-direction: row; 38 | flex-wrap: wrap; 39 | padding-left: 0em; 40 | } 41 | .flex-row > * { 42 | margin-left: 1em; 43 | } 44 | dt { font-weight: 700; } 45 | pre { 46 | color: #ccc; 47 | background: #111; 48 | font-size: 0.85em; 49 | padding: 0.5em 1em; 50 | border: var(--hair-line) solid var(--border-color); 51 | border-radius: var(--border-radius); 52 | position: relative; 53 | overflow: auto; 54 | } 55 | pre[data-title]:before { 56 | content: attr(data-title); 57 | float: right; 58 | font-size: 0.75em; 59 | } 60 | .example { 61 | border: var(--hair-line) solid var(--border-color); 62 | border-radius: var(--border-radius); 63 | padding: 0.5em 1em; 64 | margin-bottom: 1em; 65 | position: relative; 66 | } 67 | .example:before { 68 | background: var(--background); 69 | content: "Example"; 70 | display: block; 71 | font-size: 0.6em; 72 | height: 1.1em; 73 | line-height: 100%; 74 | padding: 0em 0.5em; 75 | position: absolute; 76 | text-align: left; 77 | top: -0.5em; 78 | } 79 | .to-top-wrapper { 80 | position: fixed; 81 | right: 2.5em; 82 | bottom: 2em; 83 | z-index: var(--stack-skip-link); 84 | } 85 | .to-top[href="#top"][role="button"] { 86 | display: inline-block; 87 | text-decoration: none; 88 | text-align: center; 89 | background: var(--text); 90 | color: var(--negative-text); 91 | font-weight: 700; 92 | } 93 | h2, li { word-wrap: wrap-word; } 94 | .color-example { 95 | border: var(--hair-line) solid var(--border-color); 96 | display: inline-block; 97 | height: 1em; 98 | vertical-align: -0.15em; 99 | width: 1em; 100 | } -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atk/solid-blocks/8bc870dea8ff2cab87d14a4d5a9d08adebf90e7b/src/assets/favicon.ico -------------------------------------------------------------------------------- /src/blocks/Helpers.docs.tsx: -------------------------------------------------------------------------------- 1 | export const HelpersDocs = () => <> 2 |

createLocalStorageSignal

3 |

This helper ties a signal to localStorage.

4 |
 5 |     {`
 6 | createLocalStorageSignal(
 7 |   key: string,
 8 |   initialValue?: string,
 9 |   useJson?: false
10 | ): [Accessor, Setter]
11 | 
12 | createLocalStorageSignal(
13 |   key: string,
14 |   initialValue?: T,
15 |   useJson: true
16 | ): [Accessor, Setter]`}
17 |   
18 |

If the initial value is null or undefined, it will not be set to localStorage.

19 |
20 |

useMediaQuery

21 |

This allows to react on media query changes.

22 |
23 |     {`useMediaQuery(query: MediaQuery) => Accessor`}
24 |   
25 |

For example, if you want an element to be shown only on mobile devices, you could use:

26 |
27 |     {`
28 | const isMobile = useMediaQuery('(max-width: 40em)');
29 | return Only shown in mobile`}
30 |   
31 |
32 | -------------------------------------------------------------------------------- /src/blocks/accordion.css: -------------------------------------------------------------------------------- 1 | summary.sb-accordion-header { 2 | border-bottom: var(--hair-line) solid var(--border-color); 3 | clear: right; 4 | cursor: pointer; 5 | list-style: none; 6 | } 7 | summary.sb-accordion-header:before { 8 | box-sizing: content-box; 9 | content: var(--accordion-icon); 10 | float: right; 11 | height: 0.75em; 12 | overflow: hidden; 13 | padding: 0 0.5em 0.5em 0; 14 | text-align: right; 15 | transform: rotateX(180deg); 16 | transition: transform 0.25s linear; 17 | width: 0.75em; 18 | } 19 | details.sb-accordion[open] > summary.sb-accordion-header:before { 20 | transform: rotateX(0deg); 21 | } 22 | -------------------------------------------------------------------------------- /src/blocks/accordion.docs.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal } from 'solid-js'; 2 | import { Accordion, AccordionGroup, AccordionHeader } from './accordion'; 3 | import { Checkbox } from './checkbox'; 4 | 5 | export const AccordionDocs = () => { 6 | const [exampleOpen, setExampleOpen] = createSignal(false); 7 | const [events, setEvents] = createSignal(''); 8 | const [allowMultiple, setAllowMultiple] = createSignal(false); 9 | const [allowToggle, setAllowToggle] = createSignal(false); 10 | 11 | return <> 12 |

Accordion

13 |

The <Accordion> component is meant to present a summary and disclose details on toggle interaction.

14 |

Simple example

15 |
16 |       {`
17 | 
18 |   Header
19 |   

Arbitrary content

20 |
`} 21 |
22 |
23 | 24 | Header 25 |

Arbitrary content

26 |
27 |
28 |

Properties

29 |
30 |       {`
31 | AccordionProps {
32 |   open?: boolean;
33 |   setOpen?: (open?: boolean) => void;
34 | }`}
35 |     
36 |
37 |
open
38 |
Allows to set the open state from outside. If the outside state will not differ from the internal state, it will not change anything
39 |
setOpen
40 |
An optional callback that will receive changes to the open state as a boolean argument
41 |
42 |

Effect

43 | open: boolean{" "} 44 |
45 |       {events()}
46 |     
47 |
48 | setEvents((e) => `setOpen(${open})\n${e}`)} 51 | > 52 | Static header 53 |

static content

54 |
55 |
56 |
57 |

AccordionGroup

58 |

The <AccordionGroup> allows grouping multiple accordions to enforce certain behavior, i.e. if multiple accordions may be toggled or if the last accordion may be closed again.

59 |

Properties

60 |
61 |       {`
62 | AccordionGroupProps {
63 |   allowMultiple?: boolean;
64 |   allowToggle?: boolean;
65 | }`}
66 |     
67 |
68 |
allowMultiple
69 |
if true, multiple accordions can be opened at a time; the last one will remain open, except if allowToggle is set to true:
70 |
allowToggle
71 |
if true, the last remaining accordion can be closed even though allowMultiple is set to false
72 |
73 |

Effect

74 | allowMultiple{" "} 75 | allowToggle

76 |
77 | 78 | 79 | First Accordion 80 |

Opened content of first accordion

81 |
82 | 83 | Second Accordion 84 |

Opened content of second accordion

85 |
86 | 87 | Third Accordion 88 |

Opened content of third accordion

89 |
90 |
91 |
92 |
93 | 94 | } -------------------------------------------------------------------------------- /src/blocks/accordion.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | createEffect, 4 | createSignal, 5 | mergeProps, 6 | on, 7 | onMount, 8 | splitProps, 9 | useContext, 10 | } from "solid-js"; 11 | import type { Component, JSX } from "solid-js"; 12 | import { runEvent } from "./tools"; 13 | 14 | import "./base.css"; 15 | import "./accordion.css"; 16 | 17 | declare module "solid-js" { 18 | namespace JSX { 19 | interface Directives { 20 | closeAccordion: {} 21 | } 22 | } 23 | }; 24 | 25 | export type _C = JSX.Directives['closeAccordion']; 26 | 27 | const accordionContext = createContext<[ 28 | options: { allowMultiple?: boolean, allowToggle?: boolean }, 29 | /** get the index of the current accordion */ 30 | getIndex: () => number, 31 | /** 32 | * if allowMultiple is false, the index of the currently opened accordion or -1 if none is opened 33 | * otherwise the number of opened tabs 34 | */ 35 | opened: () => number, 36 | 37 | setOpened: (opened: number) => void 38 | ] | []>([]); 39 | 40 | export type AccordionProps = JSX.HTMLAttributes & { 41 | open?: boolean, 42 | setOpen?: (isOpen: boolean) => void 43 | }; 44 | 45 | export const Accordion: Component = (props) => { 46 | const [local, detailsProps] = splitProps(props, ["setOpen"]); 47 | const [accordionRef, closeAccordion] = createSignal(); 48 | closeAccordion; 49 | const [options, getIndex, opened, setOpened] = useContext(accordionContext); 50 | const index = getIndex ? getIndex() : 0; 51 | 52 | if (options && opened && setOpened) { 53 | onMount(() => { 54 | if (props.open) { setOpened(options.allowMultiple ? opened() + 1 : index); } 55 | }); 56 | 57 | createEffect(on([opened, accordionRef], ([open, ref]) => { 58 | if (ref && options.allowMultiple === false && open !== index) { 59 | ref.open = false; 60 | } 61 | }, { defer: true })); 62 | } 63 | 64 | return ( 65 |
{ 70 | runEvent(ev, ev.currentTarget); 71 | if (accordionRef()?.open && !options.allowToggle && (!options.allowMultiple || opened() === 1)) { 72 | ev.preventDefault(); 73 | } 74 | } : props.onClick} 75 | onToggle={(ev) => { 76 | if (options && opened && setOpened) { 77 | if (options.allowMultiple === true) { 78 | setOpened(opened() + (ev.currentTarget.open ? 1 : -1)); 79 | } else if (ev.currentTarget.open) { 80 | setOpened(index); 81 | } 82 | } 83 | runEvent(ev, ev.currentTarget); 84 | local.setOpen?.(ev.currentTarget.open); 85 | }} 86 | open={!!props.open} 87 | /> 88 | ); 89 | }; 90 | 91 | export type AccordionHeaderProps = JSX.HTMLAttributes; 92 | 93 | export const AccordionHeader: Component = (props) => ( 94 | 100 | ); 101 | 102 | export type AccordionGroupProps = JSX.HTMLAttributes & { 103 | /** opening another accordion does not close the last opened one */ 104 | allowMultiple?: boolean; 105 | /** the last open accordion may be closed */ 106 | allowToggle?: boolean; 107 | }; 108 | 109 | export const AccordionGroup: Component = (props) => { 110 | const [local, divProps] = splitProps(props, ["allowMultiple", "allowToggle"]); 111 | let accordions = -1; 112 | const [opened, setOpened] = createSignal(0); 113 | 114 | return ( 115 | ++accordions, opened, setOpened]}> 116 |
122 | 123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /src/blocks/avatar.css: -------------------------------------------------------------------------------- 1 | .sb-avatar:not([role="group"]) { 2 | align-items: center; 3 | border: var(--hair-line) solid var(--border-color); 4 | border-radius: var(--round-border-radius); 5 | display: inline-flex; 6 | font-size: 0.875em; 7 | font-weight: 700; 8 | height: 2.25em; 9 | justify-content: center; 10 | line-height: 2.25em; 11 | position: relative; 12 | vertical-align: middle; 13 | width: 2.25em; 14 | } 15 | .sb-avatar > img:first-child { 16 | border-radius: var(--round-border-radius); 17 | min-height: 100%; 18 | min-width: 100%; 19 | max-height: 100%; 20 | max-width: 100%; 21 | object-fit: cover; 22 | overflow: hidden; 23 | position: absolute; 24 | } 25 | .sb-avatar[role="group"] { 26 | display: inline-block; 27 | perspective: 1000em; 28 | transform-style: preserve-3d; 29 | } 30 | .sb-avatar[role="group"][aria-expanded="true"] .sb-avatar + .sb-avatar { 31 | margin-left: 0.25em; 32 | } 33 | .sb-avatar[role="group"]:not([aria-expanded="true"]) .sb-avatar + .sb-avatar { 34 | margin-left: -0.5em; 35 | transform: translateZ(-0.1em); 36 | } 37 | .sb-avatar[role="group"]:not([aria-expanded="true"]) 38 | .sb-avatar 39 | + .sb-avatar:nth-child(3) { 40 | transform: translateZ(-0.2em); 41 | } 42 | .sb-avatar[role="group"]:not([aria-expanded="true"]) { 43 | counter-reset: items; 44 | } 45 | .sb-avatar[role="group"]:not([aria-expanded="true"]) 46 | .sb-avatar:nth-child(3):not(:last-child), 47 | .sb-avatar[role="group"]:not([aria-expanded="true"]) 48 | .sb-avatar 49 | + .sb-avatar 50 | + .sb-avatar 51 | + .sb-avatar { 52 | counter-increment: items; 53 | } 54 | .sb-avatar[role="group"]:not([aria-expanded="true"]) 55 | .sb-avatar 56 | + .sb-avatar 57 | + .sb-avatar:not(:last-child) { 58 | height: 0; 59 | opacity: 0; 60 | position: absolute; 61 | width: 0; 62 | } 63 | .sb-avatar[role="group"]:not([aria-expanded="true"]) 64 | .sb-avatar 65 | + .sb-avatar 66 | + .sb-avatar 67 | + .sb-avatar:last-child { 68 | overflow: hidden; 69 | text-indent: -10em; 70 | transform: translateZ(-0.2em); 71 | } 72 | .sb-avatar[role="group"]:not([aria-expanded="true"]) 73 | .sb-avatar 74 | + .sb-avatar 75 | + .sb-avatar 76 | + .sb-avatar:last-child:before { 77 | background: var(--border-color); 78 | border: inherit; 79 | border-radius: var(--round-border-radius); 80 | content: "+" counter(items); 81 | font-weight: 400; 82 | font-size: 0.85em; 83 | position: absolute; 84 | text-align: center; 85 | text-indent: 0; 86 | width: inherit; 87 | } 88 | .sb-avatar[role="group"]:not([aria-expanded="true"]) 89 | .sb-avatar 90 | + .sb-avatar:nth-child(101) 91 | ~ .sb-avatar:last-child:before { 92 | font-size: 0.6em; 93 | text-indent: 0.2em; 94 | } 95 | .sb-avatar[role="group"]:not([aria-expanded="true"])[data-plus] 96 | .sb-avatar 97 | + .sb-avatar 98 | + .sb-avatar 99 | + .sb-avatar:last-child:before { 100 | content: "\80" 101 | } 102 | .sb-avatar[role="group"]:not([aria-expanded="true"])[data-plus]:after { 103 | content: attr(data-plus); 104 | font-weight: 400; 105 | font-size: 0.7em; 106 | margin-left: -2.5em; 107 | margin-top: 0.3em; 108 | overflow: hidden; 109 | position: absolute; 110 | text-align: center; 111 | text-indent: 0; 112 | width: 2.25em; 113 | } 114 | .sb-avatar[data-random="1"] { 115 | background: var(--random-color1); 116 | } 117 | .sb-avatar[data-random="2"] { 118 | background: var(--random-color2); 119 | } 120 | .sb-avatar[data-random="3"] { 121 | background: var(--random-color3); 122 | } 123 | .sb-avatar[data-random="4"] { 124 | background: var(--random-color4); 125 | } 126 | .sb-avatar[data-random="5"] { 127 | background: var(--random-color5); 128 | } 129 | .sb-avatar[data-random="6"] { 130 | background: var(--random-color6); 131 | } 132 | .sb-avatar[data-random="7"] { 133 | background: var(--random-color7); 134 | } 135 | .sb-avatar[data-random="8"] { 136 | background: var(--random-color8); 137 | } 138 | .sb-avatar > .sb-badge { 139 | background: #d00; 140 | border-radius: 50%; 141 | border-color: #fff; 142 | border: 0.075em solid; 143 | bottom: 0; 144 | height: 0.5em; 145 | right: 0; 146 | position: absolute; 147 | width: 0.5em; 148 | } 149 | .sb-avatar[role="group"] .sb-avatar:nth-child(3):not(:last-child) .sb-badge { 150 | display: none; 151 | } 152 | -------------------------------------------------------------------------------- /src/blocks/avatar.docs.tsx: -------------------------------------------------------------------------------- 1 | ;import { Avatar, AvatarBadge, AvatarGroup } from "./avatar" 2 | import { Checkbox } from './checkbox'; 3 | import { createSignal } from "solid-js"; 4 | 5 | export const AvatarDocs = () => { 6 | const [expanded, setExpanded] = createSignal(false); 7 | return <> 8 |

Avatar

9 |

The <Avatar> component is meant to represent individuals, either by image or their name's initials. By default, the background will be selected randomly out of 8 colors; direct repetitions are avoided.

10 |

Properties

11 |
12 |       {`
13 | AvatarProps {
14 |   img?: string;
15 |   name?: string;
16 |   fallback?: JSX.Element;
17 | }
18 | `}
19 |     
20 |
21 | without props: , with fallback: , with name: and with image: 22 |
23 |
24 |

AvatarBadge

25 |

Will present a badge at the lower right corner of the avatar.

26 |
27 | 28 |
29 |
30 |

AvatarGroup

31 |

Properties

32 |
33 |       {`
34 | AvatarGroupProps {
35 |   aria-expanded?: boolean;
36 | }
37 | `}
38 |     
39 |

Effect

40 |

An avatargroup allows a condensed view of 2 or more avatars that can be extended with the aria-expanded-Attribute.

41 | aria-expanded

42 |
43 | 44 | 45 | 46 | 47 | 48 | 49 | {" or with a data-plus attribute to set the last item manually (which obviously does not work if expanded): "} 50 | 51 | 52 | 53 | 54 | 55 | 56 |
57 |
58 | 59 | } -------------------------------------------------------------------------------- /src/blocks/avatar.tsx: -------------------------------------------------------------------------------- 1 | import { Component, JSX, createMemo, splitProps, mergeProps } from "solid-js"; 2 | import { composeStyles, getRandom } from "./tools"; 3 | import "./base.css"; 4 | import "./avatar.css"; 5 | 6 | export type AvatarProps = { 7 | img?: string; 8 | name?: string; 9 | fallback?: JSX.Element; 10 | plus?: number; 11 | } & JSX.HTMLAttributes; 12 | 13 | const char = 14 | "[A-Za-z\xAA\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02B8\u02E0-\u02E4\u0370-\u0373\u0375-\u0377\u037A-\u037D\u037F\u0384\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03E1\u03F0-\u0484\u0487-\u052F\u1C80-\u1C88\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FC4\u1FC6-\u1FD3\u1FD6-\u1FDB\u1FDD-\u1FEF\u1FF2-\u1FF4\u1FF6-\u1FFE\u2071\u207F\u2090-\u209C\u2126\u212A\u212B\u2132\u214E\u2160-\u2188\u2C60-\u2C7F\u2DE0-\u2DFF\uA640-\uA69F\uA722-\uA787\uA78B-\uA7BF\uA7C2-\uA7C6\uA7F7-\uA7FF\uAB30-\uAB5A\uAB5C-\uAB67\uFB00-\uFB06\uFE2E\uFE2F\uFF21-\uFF3A\uFF41-\uFF5A]|\uD800[\uDD40-\uDD8E\uDDA0]|\uD834[\uDE00-\uDE45]"; 15 | const initialsRegexp = new RegExp(`^.*?(${char})(?:.*\\s+\\S*?(${char}))?.*$`); 16 | 17 | export const getInitials = (name: string) => 18 | name 19 | // remove superfluous spaces and stuff in brackets 20 | .replace(/^\s+|\(.*?\)|\{.*?\}|\[.*\]|<.*?>|\s+$/g, "") 21 | // find first char of the first name and the last name 22 | .replace(initialsRegexp, "$1$2") 23 | .toUpperCase(); 24 | 25 | export const Avatar: Component = (props) => { 26 | const [local, divProps] = splitProps(props, [ 27 | "classList", 28 | "children", 29 | "img", 30 | "name", 31 | "fallback", 32 | ]); 33 | const initials = createMemo(() => 34 | local.name ? getInitials(local.name) : "" 35 | ); 36 | 37 | if (local.img) { 38 | return ( 39 |
45 | {local.name} 46 | 47 | {local.children} 48 |
49 | ); 50 | } 51 | if (local.name) { 52 | return ( 53 |
60 | {initials} 61 | {local.children} 62 |
63 | ); 64 | } 65 | return ( 66 |
73 | {local.fallback ?? "?"} 74 | {local.children} 75 |
76 | ); 77 | }; 78 | 79 | export type AvatarBadgeProps = { 80 | borderColor?: string; 81 | background?: string; 82 | } & JSX.HTMLAttributes; 83 | 84 | export const AvatarBadge: Component = (props) => { 85 | const [local, spanProps] = splitProps(props, [ 86 | "classList", 87 | "borderColor", 88 | "background", 89 | "style", 90 | ]); 91 | const composedStyle = composeStyles(local.style ?? {}, { 92 | "border-color": local.borderColor, 93 | background: local.background, 94 | }); 95 | 96 | return ( 97 | 102 | ); 103 | }; 104 | 105 | export type AvatarGroupProps = JSX.HTMLAttributes & { 106 | "data-plus"?: string; 107 | }; 108 | 109 | export const AvatarGroup: Component = (props) => ( 110 |
3} 115 | /> 116 | ); 117 | -------------------------------------------------------------------------------- /src/blocks/bar.css: -------------------------------------------------------------------------------- 1 | .sb-bar { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: space-between; 5 | position: absolute; 6 | background: var(--background); 7 | z-index: var(--stack-bar); 8 | } 9 | .sb-bar[hidden] { 10 | display: none; 11 | } 12 | .transparent.sb-bar { 13 | background: transparent; 14 | } 15 | .left.sb-bar, 16 | .right.sb-bar { 17 | flex-direction: column; 18 | } 19 | .top.sb-bar { 20 | inset: 0 0 auto 0; 21 | border-bottom: var(--hair-line) solid var(--border-color); 22 | } 23 | .right.sb-bar { 24 | inset: 0 0 0 auto; 25 | border-left: var(--hair-line) solid var(--border-color); 26 | } 27 | .bottom.sb-bar { 28 | inset: auto 0 0 0; 29 | border-top: var(--hair-line) solid var(--border-color); 30 | } 31 | .left.sb-bar { 32 | inset: 0 auto 0 0; 33 | border-right: var(--hair-line) solid var(--border-color); 34 | } 35 | .borderless.sb-bar { 36 | border-color: transparent; 37 | } 38 | .sticky.sb-bar { 39 | position: sticky; 40 | } 41 | .fixed.sb-bar { 42 | position: fixed; 43 | } 44 | .relative.sb-bar { 45 | position: relative; 46 | } 47 | .top.relative.sb-bar, 48 | .bottom.relative.sb-bar { 49 | width: 100%; 50 | } 51 | .left.relative.sb-bar, 52 | .right.relative.sb-bar { 53 | height: 100%; 54 | } 55 | .sb-bar > * { 56 | align-items: center; 57 | justify-content: center; 58 | display: flex; 59 | flex-direction: inherit; 60 | margin-right: 0.5em; 61 | margin-left: 0.5em; 62 | } 63 | .sb-bar > *:first-child:not(:only-child) { 64 | justify-content: flex-start; 65 | } 66 | .sb-bar > *:last-child:not(:only-child) { 67 | justify-content: flex-end; 68 | } 69 | .left.sb-bar > *, 70 | .right.sb-bar > * { 71 | align-items: flex-start; 72 | } 73 | .left.sb-bar > *:first-child, 74 | .right.sb-bar > *:first-child { 75 | margin-top: 0.5em; 76 | } 77 | .left.sb-bar > *:last-child, 78 | .right.sb-bar > *:last-child { 79 | margin-bottom: 0.5em; 80 | } 81 | .top.sb-bar > *:only-child, 82 | .bottom.sb-bar > *:only-child { 83 | width: 100%; 84 | } 85 | .top.sb-bar > *:first-child:nth-last-child(2), 86 | .top.sb-bar > *:first-child:nth-last-child(2) + *, 87 | .bottom.sb-bar > *:first-child:nth-last-child(2), 88 | .bottom.sb-bar > *:first-child:nth-last-child(2) + * { 89 | width: 50%; 90 | } 91 | .top.sb-bar > *:first-child:nth-last-child(3), 92 | .top.sb-bar > *:first-child:nth-last-child(3) ~ *, 93 | .bottom.sb-bar > *:first-child:nth-last-child(3), 94 | .bottom.sb-bar > *:first-child:nth-last-child(3) ~ * { 95 | width: 33.3%; 96 | } 97 | .left.sb-bar > *:only-child, 98 | .right.sb-bar > *:only-child { 99 | height: 100%; 100 | } 101 | .left.sb-bar > *:first-child:nth-last-child(2), 102 | .left.sb-bar > *:first-child:nth-last-child(2) + *, 103 | .right.sb-bar > *:first-child:nth-last-child(2), 104 | .right.sb-bar > *:first-child:nth-last-child(2) + * { 105 | height: 50%; 106 | } 107 | .left.sb-bar > *:first-child:nth-last-child(3), 108 | .left.sb-bar > *:first-child:nth-last-child(3) ~ *, 109 | .right.sb-bar > *:first-child:nth-last-child(3), 110 | .right.sb-bar > *:first-child:nth-last-child(3) ~ * { 111 | height: 33.3%; 112 | } 113 | -------------------------------------------------------------------------------- /src/blocks/bar.docs.tsx: -------------------------------------------------------------------------------- 1 | export const BarDocs = () => { 2 | return ( 3 | <> 4 |

Bar

5 |

6 | The bar component is meant either as a top/bottom bar or a hidden 7 | sidebar for a web application. Up to three containers inside it are 8 | equally spaced, which is especially useful for application layouts. 9 |

10 |

Properties

11 |
12 |         {`
13 | BarProps {
14 |   hidden?: boolean | 'true' | 'false';
15 |   mount?: HTMLElement;
16 |   placement?: 'top' | 'right' | 'bottom' | 'left';
17 |   position?: 'relative' | 'sticky' | 'fixed' | 'absolute';
18 |   portal?: boolean;
19 | }
20 | `}
21 |       
22 |
23 |
hidden
24 |
25 | Hides the bar if true or "true"; especially 26 | helpful for auto-hide scenarios or side bars that are toggled by a 27 | button 28 |
29 |
mount
30 |
31 | If portal is not false, you can determine the mount point 32 | for the portal the bar is rendered into here 33 |
34 |
placement
35 |
36 | The place the bar should be visible in. If none is given, you need to 37 | format the bar yourself with CSS 38 |
39 |
position
40 |
41 | Allows to adapt the CSS position attribute to different use cases 42 |
43 |
portal
44 |
45 | If false, the bar is rendered at the current position and 46 | not through a portal 47 |
48 |
49 |
50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/blocks/bar.tsx: -------------------------------------------------------------------------------- 1 | import { Component, JSX, mergeProps, splitProps } from "solid-js"; 2 | import { Portal } from "solid-js/web"; 3 | 4 | import "./base.css"; 5 | import "./bar.css"; 6 | 7 | export type BarProps = JSX.HTMLAttributes & { 8 | mount?: HTMLElement; 9 | placement?: "top" | "right" | "bottom" | "left"; 10 | position?: Omit; 11 | portal?: boolean; 12 | }; 13 | 14 | export const Bar: Component = (props) => { 15 | const [local, rest] = splitProps(props, [ 16 | "placement", 17 | "position", 18 | "mount", 19 | "portal", 20 | ]); 21 | const divProps = mergeProps(rest, { 22 | class: `${local.placement}${ 23 | local.position ? " " + local.position : "" 24 | } sb-bar ${props.class ? " " + props.class : ""}`, 25 | }); 26 | return local.portal === false ? ( 27 |
28 | ) : ( 29 | 30 |
31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/blocks/base.css: -------------------------------------------------------------------------------- 1 | *, 2 | *:before, 3 | *:after { 4 | box-sizing: border-box; 5 | } 6 | input, 7 | textarea, 8 | select { 9 | font: inherit; 10 | } 11 | a { 12 | color: var(--text); 13 | } 14 | p, h1, h2, h3, h4, h5, h6 { 15 | margin: 0 0 1em; 16 | padding: 0; 17 | line-height: 150%; 18 | } 19 | 20 | body { 21 | --border-radius: 0.25em; 22 | --round-border-radius: 50%; 23 | --hair-line: 0.05em; 24 | --normal-line: 0.125em; 25 | --wide-line: 0.25em; 26 | --accordion-icon: "\2303"; 27 | --breadcrumb-icon: " > "; 28 | --stack-hide: -1; 29 | --stack-base: 0; 30 | --stack-dropdown: 1000; 31 | --stack-sticky: 1100; 32 | --stack-banner: 1200; 33 | --stack-overlay: 1300; 34 | --stack-modal: 1400; 35 | --stack-bar: 1500; 36 | --stack-skip-link: 1600; 37 | --stack-toast: 1700; 38 | --stack-tooltip: 1800; 39 | } 40 | 41 | body, 42 | .light-mode { 43 | --text: #333; 44 | --negative-text: #eee; 45 | --background: #fff; 46 | --border-color: #ccc; 47 | --primary: #37c; 48 | --primary-text: #eee; 49 | --success: #dfd; 50 | --success-text: #060; 51 | --info: #ddf; 52 | --info-text: #00a; 53 | --warning: #ffd; 54 | --warning-text: #bb0; 55 | --error: #fdd; 56 | --error-text: #800; 57 | --random-color1: #89d; 58 | --random-color2: #9bd; 59 | --random-color3: #adb; 60 | --random-color4: #cc9; 61 | --random-color5: #db9; 62 | --random-color6: #d99; 63 | --random-color7: #c9a; 64 | --random-color8: #b9a; 65 | } 66 | 67 | .dark-mode { 68 | --text: #eee; 69 | --negative-text: #333; 70 | --background: #222; 71 | --border-color: #555; 72 | --success: #232; 73 | --success-text: #0c0; 74 | --info: #224; 75 | --info-text: #22f; 76 | --warning: #442; 77 | --warning-text: #ff0; 78 | --error: #422; 79 | --error-text: #b00; 80 | --random-color1: #238; 81 | --random-color2: #278; 82 | --random-color3: #487; 83 | --random-color4: #683; 84 | --random-color5: #862; 85 | --random-color6: #933; 86 | --random-color7: #827; 87 | --random-color8: #729; 88 | } 89 | 90 | body, 91 | .dark-mode, 92 | .light-mode { 93 | background: var(--background); 94 | color: var(--text); 95 | } 96 | -------------------------------------------------------------------------------- /src/blocks/breadcrumbs.css: -------------------------------------------------------------------------------- 1 | nav.sb-breadcrumbs > ol { 2 | list-style: none; 3 | padding-left: 0; 4 | } 5 | nav.sb-breadcrumbs > ol > li { 6 | display: inline; 7 | } 8 | nav.sb-breadcrumbs > ol > li + li:before { 9 | content: var(--breadcrumb-icon); 10 | } 11 | -------------------------------------------------------------------------------- /src/blocks/breadcrumbs.docs.tsx: -------------------------------------------------------------------------------- 1 | import { Breadcrumbs } from "./breadcrumbs"; 2 | 3 | export const BreadcrumbsDocs = () => { 4 | return ( 5 | <> 6 | 7 |

8 | The breadcrumbs component is as a means of navigating through 9 | encapsulated routes of a multi-page application. It will wrap its 10 | children into an ordered list. 11 |

12 |
13 |         {`
14 | 
15 |   Home
16 |   Docs
17 |   Breadcrumbs
18 | 
19 | `}
20 |       
21 |
22 | 23 | Home 24 | Docs 25 | Breadcrumbs 26 | 27 |
28 |
29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/blocks/breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { Component, JSX, For } from "solid-js"; 2 | 3 | import "./base.css"; 4 | import "./breadcrumbs.css"; 5 | 6 | export type BreadcrumbsProps = JSX.HTMLAttributes & { 7 | children: JSX.Element | JSX.Element[]; 8 | }; 9 | 10 | export const Breadcrumbs: Component = (props) => ( 11 | 20 | ); 21 | -------------------------------------------------------------------------------- /src/blocks/button.css: -------------------------------------------------------------------------------- 1 | button.sb-button, 2 | [role="button"].sb-button { 3 | appearance: none; 4 | border: 0.125em solid; 5 | border-radius: var(--border-radius); 6 | cursor: pointer; 7 | font-size: inherit; 8 | padding: 0.25em 0.75em; 9 | margin: 0.05em 0; 10 | } 11 | button.primary.sb-button, 12 | [role="button"].primary.sb-button { 13 | border-color: var(--primary); 14 | background: var(--primary); 15 | color: var(--primary-text); 16 | } 17 | button.sb-button:hover:not(:active, [aria-disabled="true"]), 18 | button.sb-button:focus:not(:active, [aria-disabled="true"]), 19 | [role="button"].sb-button:hover:not(:active, [aria-disabled="true"]), 20 | [role="button"].sb-button:focus:not(:active, [aria-disabled="true"]) { 21 | box-shadow: 0 0 0 0.125em var(--background), 0 0 0 0.25em var(--primary); 22 | text-decoration: none; 23 | } 24 | button.sb-button[aria-disabled="true"], 25 | [role="button"].sb-button[aria-disabled="true"] { 26 | background: var(--border-color); 27 | border-color: var(--border-color); 28 | cursor: not-allowed; 29 | } 30 | button.secondary.sb-button, 31 | [role="button"].secondary.sb-button { 32 | background: transparent; 33 | border-color: var(--primary); 34 | color: var(--text); 35 | } 36 | button.link.sb-button, 37 | [role="button"].link.sb-button { 38 | background: transparent; 39 | border-color: transparent; 40 | color: var(--text); 41 | text-decoration: underline; 42 | } 43 | button.link.sb-button:hover:not(:active, [aria-disabled="true"]), 44 | button.link.sb-button:focus:not(:active, [aria-disabled="true"]), 45 | [role="button"].link.sb-button:hover:not(:active, [aria-disabled="true"]), 46 | [role="button"].link.sb-button:hover:not(:active, [aria-disabled="true"]) { 47 | border-color: var(--primary); 48 | } 49 | button.sb-button:not(:first-child), 50 | [role="button"].sb-button:not(:first-child) { 51 | margin-left: 0.25em; 52 | } 53 | button.sb-button:not(:last-child), 54 | [role="button"].sb-button:not(:last-child) { 55 | margin-right: 0.25em; 56 | } 57 | button.icon.sb-button, 58 | [role="button"].icon.sb-button { 59 | padding: 0.125em; 60 | border-color: transparent; 61 | min-width: 1.75em; 62 | background: transparent; 63 | color: var(--text); 64 | } 65 | -------------------------------------------------------------------------------- /src/blocks/button.docs.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from './button' 2 | 3 | export const ButtonDocs = () => { 4 | return ( 5 | <> 6 |

Button

7 |

8 | The button component is meant as a versatile form or standalone button. 9 |

10 |
11 | It comes as , , and Icon button. 12 |
13 |

Properties

14 |
15 |         {`
16 | ButtonProps {
17 |   variant?: 'primary' | 'secondary' | 'link' | 'icon';
18 | }
19 | `}
20 |       
21 |

The default variant is primary. Otherwise, all HTML attributes applying to the button element can be used here.

22 |
23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/blocks/button.tsx: -------------------------------------------------------------------------------- 1 | import { Component, JSX, mergeProps, splitProps } from "solid-js"; 2 | 3 | import "./base.css"; 4 | import "./button.css"; 5 | 6 | export type ButtonProps = JSX.HTMLAttributes & { 7 | variant?: "primary" | "secondary" | "link" | "icon"; 8 | }; 9 | 10 | export const Button: Component = (props) => { 11 | const [local, buttonProps] = splitProps(props, ["variant", "classList"]); 12 | 13 | return ( 14 | 73 | 74 | 75 | Header 76 | 83 | 84 | 85 |

86 | Body of the Modal. You can fill it with whatever content. 87 |

88 |
89 | 90 | 91 | 92 |
93 | 94 | )} 95 | 96 |
97 |
{`
 98 | 
 99 | {({ open, toggle }) => (
100 |   <>
101 |     
106 |     
107 |       
108 |         Header
109 |         
116 |       
117 |       
118 |         

119 | Body of the Modal. You can fill it with whatever content. 120 |

121 |
122 | 123 | 124 | 125 |
126 | 127 | )} 128 |
`}
129 |
130 | 131 | ); 132 | }; 133 | -------------------------------------------------------------------------------- /src/blocks/modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Accessor, 3 | Component, 4 | JSX, 5 | splitProps, 6 | createEffect, 7 | createSignal, 8 | createMemo, 9 | mergeProps, 10 | Switch, 11 | Match, 12 | } from "solid-js"; 13 | import { Portal } from "solid-js/web"; 14 | 15 | import "./base.css"; 16 | import "./modal.css"; 17 | import { getElements, WrappedElement } from "./tools"; 18 | 19 | export type WrappedModalContentProps = { 20 | open: Accessor; 21 | /** 22 | * toggle 23 | * 24 | * if called with boolean argument, it will set the open state 25 | * according to the argument, otherwise toggle it 26 | */ 27 | toggle: (open?: boolean | unknown) => void; 28 | }; 29 | 30 | export type ModalProps = Omit< 31 | JSX.HTMLAttributes, 32 | "children" 33 | > & { 34 | closeOnClickOutside?: boolean; 35 | closeOnEsc?: boolean; 36 | open?: boolean; 37 | noPortal?: boolean; 38 | children: WrappedElement | JSX.Element; 39 | }; 40 | 41 | let modalCount = 0; 42 | 43 | export const Modal = (props: ModalProps): JSX.Element => { 44 | const [local, containerProps] = splitProps(props, [ 45 | "open", 46 | "noPortal", 47 | "children", 48 | ]); 49 | const [open, setOpen] = createSignal(local.open); 50 | const toggle = (open?: boolean) => 51 | setOpen(typeof open === "boolean" ? open : (o) => !o); 52 | const modalContent = createMemo(() => 53 | getElements( 54 | local.children, 55 | (node) => node.className.indexOf("sb-modal-content") !== -1, 56 | [{ open, toggle }] 57 | ) ?? [] 58 | ); 59 | const otherChildren = createMemo(() => 60 | getElements( 61 | local.children, 62 | (node) => node.className.indexOf("sb-modal-content") === -1, 63 | [{ open, toggle }] 64 | ) 65 | ); 66 | 67 | let modalRef!: HTMLDivElement; 68 | createEffect(() => open() && (modalRef?.focus(), modalRef?.scrollIntoView())); 69 | 70 | modalCount++; 71 | 72 | createEffect(() => { 73 | if (!modalRef) { 74 | return; 75 | } 76 | const header = modalRef.querySelector(".sb-modal-header"); 77 | if (header) { 78 | modalRef.setAttribute( 79 | "aria-labelledby", 80 | header.id || (() => (header.id = `sb-modal-header-${modalCount}`))() 81 | ); 82 | } 83 | const body = modalRef.querySelector(".sb-modal-body"); 84 | if (body) { 85 | modalRef.setAttribute( 86 | "aria-describedby", 87 | body.id || (() => (body.id = `sb-modal-body-${modalCount}`))() 88 | ); 89 | } 90 | }); 91 | 92 | const divProps = mergeProps(containerProps, { 93 | role: "dialog" as JSX.HTMLAttributes['role'], 94 | tabIndex: -1, 95 | class: props.class ? `sb-modal ${props.class}` : "sb-modal", 96 | children: modalContent(), 97 | onClick: createMemo(() => 98 | props.closeOnClickOutside 99 | ? (ev: MouseEvent) => { 100 | const target = ev.target as HTMLElement; 101 | if (!modalContent().some((content) => content?.contains(target))) { 102 | toggle(false); 103 | } 104 | } 105 | : undefined 106 | )(), 107 | onkeyup: createMemo(() => 108 | props.closeOnEsc !== false 109 | ? (ev: KeyboardEvent) => { 110 | console.log(ev); 111 | if (ev.key === "Escape" && !ev.defaultPrevented) { 112 | setOpen(false); 113 | } 114 | } 115 | : undefined 116 | )(), 117 | }); 118 | 119 | return ( 120 | 121 | {otherChildren()} 122 | 123 | <> 124 | {otherChildren()} 125 |
126 | 127 | 128 | 129 | <> 130 | {otherChildren()} 131 | 132 |
133 | 134 | 135 | 136 | 137 | ); 138 | }; 139 | 140 | export type ModalContentProps = JSX.HTMLAttributes; 141 | 142 | export const ModalContent: Component = (props) => ( 143 |
147 | ); 148 | 149 | export type ModalHeaderProps = JSX.HTMLAttributes; 150 | 151 | export const ModalHeader: Component = (props) => ( 152 |
156 | ); 157 | 158 | export type ModalBodyProps = JSX.HTMLAttributes; 159 | 160 | export const ModalBody: Component = (props) => ( 161 |
165 | ); 166 | 167 | export type ModalFooterProps = JSX.HTMLAttributes; 168 | 169 | export const ModalFooter: Component = (props) => ( 170 |