@domicile-desktop/component-library 0.0.0-alpha-f2862631a576
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -0
- package/package.json +90 -0
- package/src/Autocomplete/Autocomplete.tsx +339 -0
- package/src/Avatar/Avatar.tsx +124 -0
- package/src/Avatar/gradient.ts +17 -0
- package/src/Avatar/initials.ts +19 -0
- package/src/Avatar/slowImageDecorator.tsx +92 -0
- package/src/Button/Button.tsx +285 -0
- package/src/Card/Card.tsx +55 -0
- package/src/Field/Field.tsx +136 -0
- package/src/Field/FieldErrorPopover.tsx +144 -0
- package/src/Input/Input.tsx +157 -0
- package/src/Kbd/Kbd.tsx +25 -0
- package/src/ModalDialog/ModalDialog.tsx +367 -0
- package/src/Popover/Popover.tsx +236 -0
- package/src/Provider/Provider.tsx +29 -0
- package/src/Screen/DisplayProvider.tsx +105 -0
- package/src/Screen/Screen.tsx +122 -0
- package/src/Screen/display-source.ts +64 -0
- package/src/Select/Select.tsx +576 -0
- package/src/SlideOver/SlideOver.tsx +176 -0
- package/src/Slider/Slider.tsx +91 -0
- package/src/TabRail/TabRail.tsx +355 -0
- package/src/Tabs/Tabs.tsx +163 -0
- package/src/Textarea/Textarea.tsx +270 -0
- package/src/ThemeSwitch/ThemeProvider.tsx +130 -0
- package/src/ThemeSwitch/ThemeSwitch.tsx +174 -0
- package/src/ThemeSwitch/standalone-theme-source.ts +42 -0
- package/src/ThemeSwitch/theme-core.ts +172 -0
- package/src/ThemeSwitch/theme-source.ts +65 -0
- package/src/Toaster/Toaster.tsx +250 -0
- package/src/__test__/AnyComponent.ts +11 -0
- package/src/__test__/Variants.tsx +89 -0
- package/src/__test__/adornmentVariations.tsx +202 -0
- package/src/__test__/iconControl.tsx +20 -0
- package/src/__test__/invalidDecorator.tsx +14 -0
- package/src/__test__/runControlSuite.tsx +212 -0
- package/src/__test__/wrapperOf.ts +14 -0
- package/src/_control/PrefixIconStack.tsx +153 -0
- package/src/_control/ResizeHandle.tsx +136 -0
- package/src/_control/TrailingGroup.tsx +204 -0
- package/src/_control/clearControl.ts +20 -0
- package/src/_control/controlSizingStyle.ts +37 -0
- package/src/_control/focus.ts +34 -0
- package/src/_control/multilineSizeStyles.ts +35 -0
- package/src/_control/useControlValue.ts +31 -0
- package/src/_control/useStableRef.ts +71 -0
- package/src/_control/wrapperBase.ts +163 -0
- package/src/control-sizes.ts +46 -0
- package/src/extend-props.ts +9 -0
- package/src/pandacss-preset.ts +492 -0
- package/src/spacing.ts +17 -0
- package/src/vite-shell.ts +118 -0
- package/styled-system/css/conditions.mjs +36 -0
- package/styled-system/css/css.d.ts +22 -0
- package/styled-system/css/css.mjs +45 -0
- package/styled-system/css/cva.d.ts +6 -0
- package/styled-system/css/cva.mjs +87 -0
- package/styled-system/css/cx.d.ts +5 -0
- package/styled-system/css/cx.mjs +15 -0
- package/styled-system/css/index.d.ts +5 -0
- package/styled-system/css/index.mjs +4 -0
- package/styled-system/css/sva.d.ts +4 -0
- package/styled-system/css/sva.mjs +46 -0
- package/styled-system/helpers.mjs +328 -0
- package/styled-system/jsx/aspect-ratio.d.ts +10 -0
- package/styled-system/jsx/aspect-ratio.mjs +14 -0
- package/styled-system/jsx/bleed.d.ts +10 -0
- package/styled-system/jsx/bleed.mjs +14 -0
- package/styled-system/jsx/box.d.ts +10 -0
- package/styled-system/jsx/box.mjs +14 -0
- package/styled-system/jsx/center.d.ts +10 -0
- package/styled-system/jsx/center.mjs +14 -0
- package/styled-system/jsx/circle.d.ts +10 -0
- package/styled-system/jsx/circle.mjs +14 -0
- package/styled-system/jsx/container.d.ts +10 -0
- package/styled-system/jsx/container.mjs +14 -0
- package/styled-system/jsx/cq.d.ts +10 -0
- package/styled-system/jsx/cq.mjs +14 -0
- package/styled-system/jsx/create-style-context.d.ts +54 -0
- package/styled-system/jsx/create-style-context.mjs +121 -0
- package/styled-system/jsx/divider.d.ts +10 -0
- package/styled-system/jsx/divider.mjs +14 -0
- package/styled-system/jsx/factory-helper.mjs +22 -0
- package/styled-system/jsx/factory.d.ts +3 -0
- package/styled-system/jsx/factory.mjs +89 -0
- package/styled-system/jsx/flex.d.ts +10 -0
- package/styled-system/jsx/flex.mjs +14 -0
- package/styled-system/jsx/float.d.ts +10 -0
- package/styled-system/jsx/float.mjs +14 -0
- package/styled-system/jsx/grid-item.d.ts +10 -0
- package/styled-system/jsx/grid-item.mjs +14 -0
- package/styled-system/jsx/grid.d.ts +10 -0
- package/styled-system/jsx/grid.mjs +14 -0
- package/styled-system/jsx/hstack.d.ts +10 -0
- package/styled-system/jsx/hstack.mjs +14 -0
- package/styled-system/jsx/index.d.ts +25 -0
- package/styled-system/jsx/index.mjs +23 -0
- package/styled-system/jsx/is-valid-prop.d.ts +11 -0
- package/styled-system/jsx/is-valid-prop.mjs +17 -0
- package/styled-system/jsx/link-overlay.d.ts +10 -0
- package/styled-system/jsx/link-overlay.mjs +14 -0
- package/styled-system/jsx/spacer.d.ts +10 -0
- package/styled-system/jsx/spacer.mjs +14 -0
- package/styled-system/jsx/square.d.ts +10 -0
- package/styled-system/jsx/square.mjs +14 -0
- package/styled-system/jsx/stack.d.ts +10 -0
- package/styled-system/jsx/stack.mjs +14 -0
- package/styled-system/jsx/visually-hidden.d.ts +10 -0
- package/styled-system/jsx/visually-hidden.mjs +14 -0
- package/styled-system/jsx/vstack.d.ts +10 -0
- package/styled-system/jsx/vstack.mjs +14 -0
- package/styled-system/jsx/wrap.d.ts +10 -0
- package/styled-system/jsx/wrap.mjs +14 -0
- package/styled-system/patterns/aspect-ratio.d.ts +20 -0
- package/styled-system/patterns/aspect-ratio.mjs +38 -0
- package/styled-system/patterns/bleed.d.ts +21 -0
- package/styled-system/patterns/bleed.mjs +24 -0
- package/styled-system/patterns/box.d.ts +20 -0
- package/styled-system/patterns/box.mjs +15 -0
- package/styled-system/patterns/center.d.ts +20 -0
- package/styled-system/patterns/center.mjs +21 -0
- package/styled-system/patterns/circle.d.ts +20 -0
- package/styled-system/patterns/circle.mjs +25 -0
- package/styled-system/patterns/container.d.ts +20 -0
- package/styled-system/patterns/container.mjs +21 -0
- package/styled-system/patterns/cq.d.ts +21 -0
- package/styled-system/patterns/cq.mjs +21 -0
- package/styled-system/patterns/divider.d.ts +22 -0
- package/styled-system/patterns/divider.mjs +25 -0
- package/styled-system/patterns/flex.d.ts +26 -0
- package/styled-system/patterns/flex.mjs +26 -0
- package/styled-system/patterns/float.d.ts +23 -0
- package/styled-system/patterns/float.mjs +52 -0
- package/styled-system/patterns/grid-item.d.ts +25 -0
- package/styled-system/patterns/grid-item.mjs +25 -0
- package/styled-system/patterns/grid.d.ts +24 -0
- package/styled-system/patterns/grid.mjs +27 -0
- package/styled-system/patterns/hstack.d.ts +21 -0
- package/styled-system/patterns/hstack.mjs +24 -0
- package/styled-system/patterns/index.d.ts +21 -0
- package/styled-system/patterns/index.mjs +20 -0
- package/styled-system/patterns/link-overlay.d.ts +20 -0
- package/styled-system/patterns/link-overlay.mjs +24 -0
- package/styled-system/patterns/spacer.d.ts +20 -0
- package/styled-system/patterns/spacer.mjs +25 -0
- package/styled-system/patterns/square.d.ts +20 -0
- package/styled-system/patterns/square.mjs +24 -0
- package/styled-system/patterns/stack.d.ts +23 -0
- package/styled-system/patterns/stack.mjs +24 -0
- package/styled-system/patterns/visually-hidden.d.ts +20 -0
- package/styled-system/patterns/visually-hidden.mjs +18 -0
- package/styled-system/patterns/vstack.d.ts +21 -0
- package/styled-system/patterns/vstack.mjs +24 -0
- package/styled-system/patterns/wrap.d.ts +24 -0
- package/styled-system/patterns/wrap.mjs +25 -0
- package/styled-system/recipes/control.d.ts +31 -0
- package/styled-system/recipes/control.mjs +35 -0
- package/styled-system/recipes/create-recipe.mjs +82 -0
- package/styled-system/recipes/index.d.ts +2 -0
- package/styled-system/recipes/index.mjs +1 -0
- package/styled-system/tokens/index.d.ts +9 -0
- package/styled-system/tokens/index.mjs +49636 -0
- package/styled-system/tokens/tokens.d.ts +72 -0
- package/styled-system/types/composition.d.ts +227 -0
- package/styled-system/types/conditions.d.ts +320 -0
- package/styled-system/types/csstype.d.ts +22570 -0
- package/styled-system/types/global.d.ts +20 -0
- package/styled-system/types/index.d.ts +8 -0
- package/styled-system/types/jsx.d.ts +69 -0
- package/styled-system/types/parts.d.ts +8 -0
- package/styled-system/types/pattern.d.ts +78 -0
- package/styled-system/types/prop-type.d.ts +268 -0
- package/styled-system/types/recipe.d.ts +181 -0
- package/styled-system/types/selectors.d.ts +59 -0
- package/styled-system/types/static-css.d.ts +56 -0
- package/styled-system/types/style-props.d.ts +8089 -0
- package/styled-system/types/system-types.d.ts +151 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @domicile-desktop/component-library
|
|
2
|
+
|
|
3
|
+
The shared React UI primitives every Domicile app builds on. Components wrap
|
|
4
|
+
[`@base-ui/react`](https://base-ui.com) where a primitive exists — so focus
|
|
5
|
+
management, keyboard navigation, portals, and validation come from base-ui —
|
|
6
|
+
and add the project's styling and ergonomic API on top. This package also
|
|
7
|
+
owns the design system: the Panda CSS preset (tokens, the shared `control`
|
|
8
|
+
recipe) that every other package extends.
|
|
9
|
+
|
|
10
|
+
Apps MUST build on these primitives rather than rolling their own buttons,
|
|
11
|
+
inputs, or dialogs from raw HTML. If a primitive is missing, add it here and
|
|
12
|
+
consume it — don't fork. See [/docs/guidelines/STYLING.md](../../docs/guidelines/STYLING.md).
|
|
13
|
+
|
|
14
|
+
## Exports
|
|
15
|
+
|
|
16
|
+
| Export | What it is |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `@domicile-desktop/component-library/Button` | Polymorphic button (`<button>` / `<a>`), variants + sizes. |
|
|
19
|
+
| `@domicile-desktop/component-library/Input` | Text input with prefix-icon / clearable / invalid states. |
|
|
20
|
+
| `@domicile-desktop/component-library/Textarea` | Auto-sizing textarea with a resize handle. |
|
|
21
|
+
| `@domicile-desktop/component-library/Field` | Label + control + validation-message wrapper (base-ui Field). |
|
|
22
|
+
| `@domicile-desktop/component-library/Select` | Select / listbox (base-ui Select). |
|
|
23
|
+
| `@domicile-desktop/component-library/Autocomplete` | Text field with a caller-supplied list of suggestions under it (base-ui Autocomplete). The caller does the matching, so the list can rank and can offer a line that matches nothing typed. |
|
|
24
|
+
| `@domicile-desktop/component-library/Tabs` | Tabbed container (base-ui Tabs): config-driven `tabs` array, a sliding active underline, `size` variants, inset focus ring. |
|
|
25
|
+
| `@domicile-desktop/component-library/TabRail` | Vertical rail of tabs with a brand slot, footer, and collapse. |
|
|
26
|
+
| `@domicile-desktop/component-library/Card` | Elevated surface with optional title / footer. |
|
|
27
|
+
| `@domicile-desktop/component-library/ModalDialog` | Modal dialog with flattened `title` / `footer` / `trigger` API. |
|
|
28
|
+
| `@domicile-desktop/component-library/SlideOver` | Edge-anchored drawer (base-ui Dialog). |
|
|
29
|
+
| `@domicile-desktop/component-library/Toaster` | Toasts as a deck in a box's top trailing corner — fans out on hover, swipes away, a countdown along each (base-ui Toast). The caller draws each card. |
|
|
30
|
+
| `@domicile-desktop/component-library/Popover` | Non-modal panel anchored to the control that opened it (base-ui Popover), for detail a control has no room for. |
|
|
31
|
+
| `@domicile-desktop/component-library/Avatar` | Avatar with initials / gradient fallback. |
|
|
32
|
+
| `@domicile-desktop/component-library/Kbd` | Keyboard-shortcut key cap. |
|
|
33
|
+
| `@domicile-desktop/component-library/Screen` | Lays its children over one of the desktop's displays, once per display it selects. |
|
|
34
|
+
| `@domicile-desktop/component-library/DisplayProvider` | The desktop the host described, for the `<Screen>`s below it. |
|
|
35
|
+
| `@domicile-desktop/component-library/display-source` | The `Display` / `DisplaySource` types a `DisplayProvider` is fed. |
|
|
36
|
+
| `@domicile-desktop/component-library/Provider` | base-ui `DirectionProvider` wrapper every app roots its tree in. |
|
|
37
|
+
| `@domicile-desktop/component-library/ThemeProvider` | Theme state (`light` / `dark` / `system`) and the `<html data-theme>` side effect. |
|
|
38
|
+
| `@domicile-desktop/component-library/ThemeSwitch` | The toggle that cycles the theme preference. |
|
|
39
|
+
| `@domicile-desktop/component-library/control-sizes` | The `Size` union / `SIZES` array the sized controls share. |
|
|
40
|
+
| `@domicile-desktop/component-library/spacing` | The rem value of one step on the spacing scale, for runtime math. |
|
|
41
|
+
| `@domicile-desktop/component-library/pandacss-preset` | The `domicilePreset` every package's `panda.config.ts` extends. |
|
|
42
|
+
| `@domicile-desktop/component-library/vite-shell` | `shellBuild({ entry })` — the vite build a shell has to have for Domicile to serve it: a module entry rather than an HTML one, a fixed `shell.js` name, the entry's exports kept (`Shell` is what Domicile calls), and the stylesheet folded into the JavaScript. Each of the four fails quietly if you get it wrong. |
|
|
43
|
+
|
|
44
|
+
Styling goes through the theme defined in the preset
|
|
45
|
+
(`pandacss-preset.ts`) — `color`, `spacing`, `borderRadius`, etc. — with
|
|
46
|
+
both dark (default) and light (`data-theme="light"`) values. Component
|
|
47
|
+
variants are exposed as explicit props, never a `className` passthrough;
|
|
48
|
+
`data-*` attributes communicate variant/state to CSS.
|
|
49
|
+
|
|
50
|
+
## Conventions
|
|
51
|
+
|
|
52
|
+
- Every component has a Storybook story (`*.stories.tsx`) with `argTypes` for
|
|
53
|
+
every prop.
|
|
54
|
+
- Every component has tests (`*.test.tsx`) using `bun:test` +
|
|
55
|
+
`@testing-library/react`.
|
|
56
|
+
- Icons come from `@phosphor-icons/react/dist/ssr/<IconName>` (the
|
|
57
|
+
`*Icon`-suffixed name), never the barrel — see
|
|
58
|
+
[/docs/guidelines/ICONS.md](../../docs/guidelines/ICONS.md).
|
|
59
|
+
|
|
60
|
+
The rules for adding or changing a component (directory layout, base-ui
|
|
61
|
+
wrapping, props typing, the `control` recipe, storybook categories, testing)
|
|
62
|
+
live in [`docs/AGENTS.md`](./docs/AGENTS.md) and the topic docs it indexes.
|
|
63
|
+
|
|
64
|
+
## Scripts
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
bun run start:dev # Storybook dev server on port 4000
|
|
68
|
+
bun run build:storybook # build static Storybook into storybook-static/
|
|
69
|
+
bun run start:prod # serve that build on port 4000
|
|
70
|
+
bun run prepare # panda codegen (generates styled-system/)
|
|
71
|
+
bun run test:unit # bun:test + happy-dom
|
|
72
|
+
bun run test:types # tsc --noEmit
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
From the repo root, `bun run turbo test --filter @domicile-desktop/component-library`
|
|
76
|
+
runs the type check and the unit tests together.
|
package/package.json
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"@base-ui/react": "^1.8.0",
|
|
4
|
+
"@phosphor-icons/react": "^2.1.10"
|
|
5
|
+
},
|
|
6
|
+
"description": "React components and the Panda CSS design system that Domicile's shells are built from.",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@cprussin/tsconfig": "^5.0.0",
|
|
9
|
+
"@domicile-desktop/test-support": "0.0.0",
|
|
10
|
+
"@pandacss/dev": "^1.12.1",
|
|
11
|
+
"@pandacss/preset-panda": "^1.12.1",
|
|
12
|
+
"@storybook/addon-docs": "^10.6.0",
|
|
13
|
+
"@storybook/addon-themes": "^10.6.0",
|
|
14
|
+
"@storybook/react-vite": "^10.6.0",
|
|
15
|
+
"@testing-library/react": "^16.3.3",
|
|
16
|
+
"@testing-library/user-event": "^14.6.7",
|
|
17
|
+
"@types/bun": "^1.4.2",
|
|
18
|
+
"@types/react": "^19.2.18",
|
|
19
|
+
"@types/react-dom": "^19.2.7",
|
|
20
|
+
"http-server": "^14.1.1",
|
|
21
|
+
"postcss": "^8.5.28",
|
|
22
|
+
"react": "^19.2.8",
|
|
23
|
+
"react-dom": "^19.2.8",
|
|
24
|
+
"storybook": "^10.6.0",
|
|
25
|
+
"typescript": "^7.0.2",
|
|
26
|
+
"vite": "^8.2.2"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
"./Autocomplete": "./src/Autocomplete/Autocomplete.tsx",
|
|
30
|
+
"./Avatar": "./src/Avatar/Avatar.tsx",
|
|
31
|
+
"./Button": "./src/Button/Button.tsx",
|
|
32
|
+
"./Card": "./src/Card/Card.tsx",
|
|
33
|
+
"./control-sizes": "./src/control-sizes.ts",
|
|
34
|
+
"./DisplayProvider": "./src/Screen/DisplayProvider.tsx",
|
|
35
|
+
"./display-source": "./src/Screen/display-source.ts",
|
|
36
|
+
"./Field": "./src/Field/Field.tsx",
|
|
37
|
+
"./Input": "./src/Input/Input.tsx",
|
|
38
|
+
"./Kbd": "./src/Kbd/Kbd.tsx",
|
|
39
|
+
"./ModalDialog": "./src/ModalDialog/ModalDialog.tsx",
|
|
40
|
+
"./Popover": "./src/Popover/Popover.tsx",
|
|
41
|
+
"./Provider": "./src/Provider/Provider.tsx",
|
|
42
|
+
"./pandacss-preset": "./src/pandacss-preset.ts",
|
|
43
|
+
"./Screen": "./src/Screen/Screen.tsx",
|
|
44
|
+
"./Select": "./src/Select/Select.tsx",
|
|
45
|
+
"./SlideOver": "./src/SlideOver/SlideOver.tsx",
|
|
46
|
+
"./Slider": "./src/Slider/Slider.tsx",
|
|
47
|
+
"./spacing": "./src/spacing.ts",
|
|
48
|
+
"./standalone-theme-source": "./src/ThemeSwitch/standalone-theme-source.ts",
|
|
49
|
+
"./TabRail": "./src/TabRail/TabRail.tsx",
|
|
50
|
+
"./Tabs": "./src/Tabs/Tabs.tsx",
|
|
51
|
+
"./Textarea": "./src/Textarea/Textarea.tsx",
|
|
52
|
+
"./ThemeProvider": "./src/ThemeSwitch/ThemeProvider.tsx",
|
|
53
|
+
"./ThemeSwitch": "./src/ThemeSwitch/ThemeSwitch.tsx",
|
|
54
|
+
"./Toaster": "./src/Toaster/Toaster.tsx",
|
|
55
|
+
"./theme-core": "./src/ThemeSwitch/theme-core.ts",
|
|
56
|
+
"./theme-source": "./src/ThemeSwitch/theme-source.ts",
|
|
57
|
+
"./vite-shell": "./src/vite-shell.ts"
|
|
58
|
+
},
|
|
59
|
+
"files": [
|
|
60
|
+
"src/**",
|
|
61
|
+
"styled-system/**",
|
|
62
|
+
"!src/**/*.test.ts",
|
|
63
|
+
"!src/**/*.test.tsx",
|
|
64
|
+
"!src/**/*.stories.tsx"
|
|
65
|
+
],
|
|
66
|
+
"license": "MIT",
|
|
67
|
+
"name": "@domicile-desktop/component-library",
|
|
68
|
+
"peerDependencies": {
|
|
69
|
+
"react": "^19.2.8"
|
|
70
|
+
},
|
|
71
|
+
"private": false,
|
|
72
|
+
"publishConfig": {
|
|
73
|
+
"access": "public"
|
|
74
|
+
},
|
|
75
|
+
"repository": {
|
|
76
|
+
"directory": "packages/component-library",
|
|
77
|
+
"type": "git",
|
|
78
|
+
"url": "git+https://github.com/cprussin/domicile.git"
|
|
79
|
+
},
|
|
80
|
+
"scripts": {
|
|
81
|
+
"build:storybook": "storybook build",
|
|
82
|
+
"prepare": "panda codegen --silent",
|
|
83
|
+
"start:dev": "storybook dev -p 4000",
|
|
84
|
+
"start:prod": "http-server storybook-static -p 4000",
|
|
85
|
+
"test:types": "tsc --noEmit",
|
|
86
|
+
"test:unit": "bun test"
|
|
87
|
+
},
|
|
88
|
+
"type": "module",
|
|
89
|
+
"version": "0.0.0-alpha-f2862631a576"
|
|
90
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { Autocomplete as BaseAutocomplete } from "@base-ui/react/autocomplete";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { css, cva, cx } from "../../styled-system/css";
|
|
4
|
+
import type { ControlVariant } from "../../styled-system/recipes";
|
|
5
|
+
import { control } from "../../styled-system/recipes";
|
|
6
|
+
import { controlSizingStyle } from "../_control/controlSizingStyle";
|
|
7
|
+
import { focusControlOnMouseDown } from "../_control/focus";
|
|
8
|
+
import { PrefixIconStack } from "../_control/PrefixIconStack";
|
|
9
|
+
import { wrapperBase } from "../_control/wrapperBase";
|
|
10
|
+
import type { ExtendProps } from "../extend-props";
|
|
11
|
+
|
|
12
|
+
export { SIZES, type Size } from "../control-sizes";
|
|
13
|
+
|
|
14
|
+
/** One line of the list under the field. */
|
|
15
|
+
export type Suggestion<V> = {
|
|
16
|
+
/** What the caller gets back when this one is taken. */
|
|
17
|
+
value: V;
|
|
18
|
+
/** What the field fills with when this one is highlighted or taken. */
|
|
19
|
+
text: string;
|
|
20
|
+
/** The line itself. */
|
|
21
|
+
label: ReactNode;
|
|
22
|
+
/** A second, quieter line under it — where the suggestion came from, what it will do. */
|
|
23
|
+
description?: ReactNode | undefined;
|
|
24
|
+
/** Drawn at the inline start of the line. */
|
|
25
|
+
icon?: ReactNode | undefined;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type CommonProps<V> = Partial<ControlVariant> & {
|
|
29
|
+
/**
|
|
30
|
+
* Whether the first suggestion highlights as soon as the user types, so
|
|
31
|
+
* Enter takes it and the field fills with it ahead of the caret.
|
|
32
|
+
*
|
|
33
|
+
* What an address bar wants: the line it is already halfway to is the first
|
|
34
|
+
* one, so Enter means "the obvious thing" without an arrow key first. A
|
|
35
|
+
* field whose first line is not the obvious thing wants this off, or Enter
|
|
36
|
+
* takes something the user never looked at.
|
|
37
|
+
*/
|
|
38
|
+
autoHighlight?: boolean | undefined;
|
|
39
|
+
disabled?: boolean | undefined;
|
|
40
|
+
/** Shown in place of the list when `suggestions` is empty. */
|
|
41
|
+
emptyMessage?: ReactNode | undefined;
|
|
42
|
+
onSuggestionTaken?: ((value: V) => void) | undefined;
|
|
43
|
+
onValueChange?: ((value: string) => void) | undefined;
|
|
44
|
+
rounded?: boolean | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* The lines to offer, already chosen and already in order.
|
|
47
|
+
*
|
|
48
|
+
* THE CALLER DOES THE MATCHING. A field that filtered what it was given
|
|
49
|
+
* could only match on the text it can see, and the interesting lists are the
|
|
50
|
+
* ones where it cannot: an address bar ranks a host the user visits daily
|
|
51
|
+
* over one they saw once, and offers a search for what was typed that
|
|
52
|
+
* matches nothing at all.
|
|
53
|
+
*/
|
|
54
|
+
suggestions: readonly Suggestion<V>[];
|
|
55
|
+
/** Controls rendered at the inline end of the field, inside its border. */
|
|
56
|
+
suffixButtons?: ReactNode | undefined;
|
|
57
|
+
value?: string | undefined;
|
|
58
|
+
width?: number | undefined;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type Props<V> = ExtendProps<
|
|
62
|
+
typeof BaseAutocomplete.Input,
|
|
63
|
+
CommonProps<V> &
|
|
64
|
+
// A prefix is either decoration or a control, and never both: the icon
|
|
65
|
+
// slot takes no pointer — it is what the invalid indicator animates in
|
|
66
|
+
// and out of — so a button put there could not be pressed. A browser's
|
|
67
|
+
// site indicator is a control, which is what `prefixButtons` is for.
|
|
68
|
+
(
|
|
69
|
+
| { prefixButtons?: undefined; prefixIcon?: ReactNode | undefined }
|
|
70
|
+
| { prefixButtons: ReactNode; prefixIcon?: undefined }
|
|
71
|
+
)
|
|
72
|
+
>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A text field with a list of suggestions under it, wrapping the
|
|
76
|
+
* `@base-ui/react` Autocomplete primitive.
|
|
77
|
+
*
|
|
78
|
+
* Same surface as `Input`: the `control` recipe and the `wrapperBase` state
|
|
79
|
+
* matrix, so its border, focus ring, hover and invalid styling are the other
|
|
80
|
+
* form controls' verbatim. What it adds is the list — arrow keys move through
|
|
81
|
+
* it, the highlighted line fills the field ahead of the caret, and Enter takes
|
|
82
|
+
* it.
|
|
83
|
+
*
|
|
84
|
+
* Highlighting fills the field rather than only marking a line, which is the
|
|
85
|
+
* behavior of every address bar: what the user would get by pressing Enter is
|
|
86
|
+
* readable without looking away from where they are typing.
|
|
87
|
+
*/
|
|
88
|
+
export const Autocomplete = <V,>({
|
|
89
|
+
autoHighlight = false,
|
|
90
|
+
disabled,
|
|
91
|
+
emptyMessage,
|
|
92
|
+
onSuggestionTaken,
|
|
93
|
+
onValueChange,
|
|
94
|
+
prefixButtons,
|
|
95
|
+
prefixIcon,
|
|
96
|
+
rounded = false,
|
|
97
|
+
size = "md",
|
|
98
|
+
suggestions,
|
|
99
|
+
suffixButtons,
|
|
100
|
+
value,
|
|
101
|
+
width,
|
|
102
|
+
...inputProps
|
|
103
|
+
}: Props<V>) => (
|
|
104
|
+
<BaseAutocomplete.Root<Suggestion<V>>
|
|
105
|
+
autoHighlight={autoHighlight}
|
|
106
|
+
disabled={disabled}
|
|
107
|
+
items={suggestions}
|
|
108
|
+
itemToStringValue={(suggestion) => suggestion.text}
|
|
109
|
+
// `inline` rather than `list` or `both`: the caller has already chosen
|
|
110
|
+
// which lines to offer — see `suggestions` — so a second round of matching
|
|
111
|
+
// inside the field would only throw away the ones it could not see a
|
|
112
|
+
// reason for.
|
|
113
|
+
mode="inline"
|
|
114
|
+
onValueChange={(next) => {
|
|
115
|
+
onValueChange?.(next);
|
|
116
|
+
}}
|
|
117
|
+
value={value}
|
|
118
|
+
>
|
|
119
|
+
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: focuses the field when the wrapper's padding is clicked; mirrors native <label> behavior without a second label polluting the accessible name */}
|
|
120
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: focuses the field when the wrapper's padding is clicked; mirrors native <label> behavior without a second label polluting the accessible name */}
|
|
121
|
+
<div
|
|
122
|
+
className={cx(
|
|
123
|
+
control({ size }),
|
|
124
|
+
wrapperBase,
|
|
125
|
+
wrapperStyles({ rounded, size }),
|
|
126
|
+
)}
|
|
127
|
+
onMouseDown={focusControlOnMouseDown}
|
|
128
|
+
style={controlSizingStyle({ width })}
|
|
129
|
+
>
|
|
130
|
+
{prefixButtons === undefined ? (
|
|
131
|
+
<PrefixIconStack prefixIcon={prefixIcon} size={size} />
|
|
132
|
+
) : (
|
|
133
|
+
<span className={affixStyles}>{prefixButtons}</span>
|
|
134
|
+
)}
|
|
135
|
+
<BaseAutocomplete.Input
|
|
136
|
+
{...inputProps}
|
|
137
|
+
className={inputStyles}
|
|
138
|
+
data-control=""
|
|
139
|
+
/>
|
|
140
|
+
{suffixButtons !== undefined && (
|
|
141
|
+
<span className={affixStyles}>{suffixButtons}</span>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
<BaseAutocomplete.Portal>
|
|
145
|
+
<BaseAutocomplete.Positioner
|
|
146
|
+
align="start"
|
|
147
|
+
className={positionerStyles}
|
|
148
|
+
side="bottom"
|
|
149
|
+
sideOffset={6}
|
|
150
|
+
>
|
|
151
|
+
<BaseAutocomplete.Popup className={popupStyles}>
|
|
152
|
+
<BaseAutocomplete.Empty className={emptyStyles}>
|
|
153
|
+
{emptyMessage}
|
|
154
|
+
</BaseAutocomplete.Empty>
|
|
155
|
+
<BaseAutocomplete.List className={listStyles}>
|
|
156
|
+
{suggestions.map((suggestion, index) => (
|
|
157
|
+
<BaseAutocomplete.Item
|
|
158
|
+
className={itemStyles}
|
|
159
|
+
index={index}
|
|
160
|
+
key={index}
|
|
161
|
+
onClick={() => {
|
|
162
|
+
onSuggestionTaken?.(suggestion.value);
|
|
163
|
+
}}
|
|
164
|
+
value={suggestion}
|
|
165
|
+
>
|
|
166
|
+
{suggestion.icon !== undefined && (
|
|
167
|
+
<span className={itemIconStyles}>{suggestion.icon}</span>
|
|
168
|
+
)}
|
|
169
|
+
<span className={itemLabelStyles}>{suggestion.label}</span>
|
|
170
|
+
{suggestion.description !== undefined && (
|
|
171
|
+
<span className={itemDescriptionStyles}>
|
|
172
|
+
{suggestion.description}
|
|
173
|
+
</span>
|
|
174
|
+
)}
|
|
175
|
+
</BaseAutocomplete.Item>
|
|
176
|
+
))}
|
|
177
|
+
</BaseAutocomplete.List>
|
|
178
|
+
</BaseAutocomplete.Popup>
|
|
179
|
+
</BaseAutocomplete.Positioner>
|
|
180
|
+
</BaseAutocomplete.Portal>
|
|
181
|
+
</BaseAutocomplete.Root>
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const inputStyles = css({
|
|
185
|
+
"&::placeholder": { color: "muted" },
|
|
186
|
+
backgroundColor: "transparent",
|
|
187
|
+
borderStyle: "none",
|
|
188
|
+
color: "foreground",
|
|
189
|
+
cursor: { _disabled: "not-allowed", base: "text" },
|
|
190
|
+
flexGrow: 1,
|
|
191
|
+
inlineSize: "100%",
|
|
192
|
+
minInlineSize: 0,
|
|
193
|
+
outlineStyle: "none",
|
|
194
|
+
padding: 0,
|
|
195
|
+
textOverflow: "ellipsis",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Both ends of the field, which want the same thing: a row of controls that
|
|
199
|
+
// keeps its size while the text beside it grows, and that inherits the
|
|
200
|
+
// control recipe's per-size gap so it is spaced like the rest of the field.
|
|
201
|
+
const affixStyles = css({
|
|
202
|
+
alignItems: "center",
|
|
203
|
+
display: "inline-flex",
|
|
204
|
+
flexShrink: 0,
|
|
205
|
+
gap: "inherit",
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// The rounded variant inflates inline padding by 1.5 spacing units on top of
|
|
209
|
+
// the `control` recipe's per-size padding (xs: 1.5, sm: 2.5, md: 3, lg: 3.5,
|
|
210
|
+
// xl: 4 — see `CONTROL_PADDING_INLINE` in `control-sizes.ts`), matching
|
|
211
|
+
// Input's `rounded` overrides. Values inlined as literals (not derived from a
|
|
212
|
+
// helper) so Panda's static extractor can emit the corresponding atomic
|
|
213
|
+
// classes — helper return values are opaque to it.
|
|
214
|
+
const wrapperStyles = cva({
|
|
215
|
+
base: {
|
|
216
|
+
// Owned here rather than in `wrapperBase` for the reason Input owns it —
|
|
217
|
+
// see the note at the top of `wrapperBase.ts`.
|
|
218
|
+
cursor: "text",
|
|
219
|
+
},
|
|
220
|
+
compoundVariants: [
|
|
221
|
+
{ css: { paddingInline: 3 }, rounded: true, size: "xs" },
|
|
222
|
+
{ css: { paddingInline: 4 }, rounded: true, size: "sm" },
|
|
223
|
+
{ css: { paddingInline: 4.5 }, rounded: true, size: "md" },
|
|
224
|
+
{ css: { paddingInline: 5 }, rounded: true, size: "lg" },
|
|
225
|
+
{ css: { paddingInline: 5.5 }, rounded: true, size: "xl" },
|
|
226
|
+
{ css: { paddingInline: 7 }, rounded: true, size: "2xl" },
|
|
227
|
+
{ css: { paddingInline: 9 }, rounded: true, size: "3xl" },
|
|
228
|
+
{ css: { paddingInline: 11.5 }, rounded: true, size: "4xl" },
|
|
229
|
+
],
|
|
230
|
+
variants: {
|
|
231
|
+
rounded: {
|
|
232
|
+
true: { borderRadius: "full" },
|
|
233
|
+
},
|
|
234
|
+
size: {
|
|
235
|
+
"2xl": {},
|
|
236
|
+
"3xl": {},
|
|
237
|
+
"4xl": {},
|
|
238
|
+
lg: {},
|
|
239
|
+
md: {},
|
|
240
|
+
sm: {},
|
|
241
|
+
xl: {},
|
|
242
|
+
xs: {},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const positionerStyles = css({
|
|
248
|
+
outlineStyle: "none",
|
|
249
|
+
zIndex: "modal",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const popupStyles = css({
|
|
253
|
+
"&[data-ending-style]": {
|
|
254
|
+
opacity: 0,
|
|
255
|
+
transform: "scaleY(0.9)",
|
|
256
|
+
transition:
|
|
257
|
+
"opacity {durations.fast} {easings.in}, transform {durations.fast} {easings.in}",
|
|
258
|
+
},
|
|
259
|
+
// base-ui's own attribute rather than Panda's `_starting`: the browser's
|
|
260
|
+
// `@starting-style` does not reliably fire for an element that mounts inside
|
|
261
|
+
// a portal, which leaves the list snapped into place with no animation.
|
|
262
|
+
"&[data-starting-style]": {
|
|
263
|
+
opacity: 0,
|
|
264
|
+
transform: "scaleY(0.9)",
|
|
265
|
+
},
|
|
266
|
+
backgroundColor: "card",
|
|
267
|
+
border: "1px solid {colors.border}",
|
|
268
|
+
borderRadius: "md",
|
|
269
|
+
boxShadow: "lifted",
|
|
270
|
+
display: "flex",
|
|
271
|
+
flexDirection: "column",
|
|
272
|
+
// The field's own width, so the list reads as a continuation of it rather
|
|
273
|
+
// than a panel that happens to be underneath. `--anchor-width` is measured
|
|
274
|
+
// by base-ui's positioner.
|
|
275
|
+
inlineSize: "var(--anchor-width)",
|
|
276
|
+
maxBlockSize: "min(60vh, {spacing.96})",
|
|
277
|
+
opacity: 1,
|
|
278
|
+
outlineStyle: "none",
|
|
279
|
+
overflow: "hidden",
|
|
280
|
+
paddingBlock: 1,
|
|
281
|
+
transform: "scaleY(1)",
|
|
282
|
+
transformOrigin: "top",
|
|
283
|
+
transition:
|
|
284
|
+
"opacity {durations.normal} {easings.out}, transform {durations.normal} {easings.out}",
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Stays mounted whether or not the list is empty — base-ui announces the
|
|
288
|
+
// change through it, and a node that is conditionally rendered announces
|
|
289
|
+
// nothing. `:empty` is what hides it when there is nothing to say.
|
|
290
|
+
const emptyStyles = css({
|
|
291
|
+
"&:empty": { display: "none" },
|
|
292
|
+
color: "muted",
|
|
293
|
+
fontSize: "sm",
|
|
294
|
+
paddingBlock: 2,
|
|
295
|
+
paddingInline: 3,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const listStyles = css({
|
|
299
|
+
flexGrow: 1,
|
|
300
|
+
minBlockSize: 0,
|
|
301
|
+
overflowY: "auto",
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const itemStyles = css({
|
|
305
|
+
"&[data-highlighted]": {
|
|
306
|
+
backgroundColor: "color-mix(in oklab, {colors.foreground} 8%, transparent)",
|
|
307
|
+
},
|
|
308
|
+
alignItems: "center",
|
|
309
|
+
color: "foreground",
|
|
310
|
+
columnGap: 2,
|
|
311
|
+
cursor: "pointer",
|
|
312
|
+
display: "grid",
|
|
313
|
+
fontSize: "sm",
|
|
314
|
+
// The icon takes what it needs, the label takes the rest, and the
|
|
315
|
+
// description is pinned at the inline end — which is the shape of every
|
|
316
|
+
// address bar's list: what you are going to, and why it is being offered.
|
|
317
|
+
gridTemplateColumns: "auto 1fr auto",
|
|
318
|
+
outlineStyle: "none",
|
|
319
|
+
paddingBlock: 1.5,
|
|
320
|
+
paddingInline: 3,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const itemIconStyles = css({
|
|
324
|
+
alignItems: "center",
|
|
325
|
+
color: "muted",
|
|
326
|
+
display: "inline-flex",
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const itemLabelStyles = css({
|
|
330
|
+
overflow: "hidden",
|
|
331
|
+
textOverflow: "ellipsis",
|
|
332
|
+
whiteSpace: "nowrap",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const itemDescriptionStyles = css({
|
|
336
|
+
color: "muted",
|
|
337
|
+
fontSize: "xs",
|
|
338
|
+
whiteSpace: "nowrap",
|
|
339
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ImageLoadingStatus } from "@base-ui/react/avatar";
|
|
2
|
+
import { Avatar as BaseAvatar } from "@base-ui/react/avatar";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { css, cva } from "../../styled-system/css";
|
|
5
|
+
import { center, circle } from "../../styled-system/patterns";
|
|
6
|
+
import type { ExtendProps } from "../extend-props";
|
|
7
|
+
|
|
8
|
+
import { getGradient } from "./gradient";
|
|
9
|
+
import { getInitials } from "./initials";
|
|
10
|
+
|
|
11
|
+
export const AVATAR_SIZES = [
|
|
12
|
+
"2xs",
|
|
13
|
+
"xs",
|
|
14
|
+
"sm",
|
|
15
|
+
"md",
|
|
16
|
+
"lg",
|
|
17
|
+
"xl",
|
|
18
|
+
"2xl",
|
|
19
|
+
] as const;
|
|
20
|
+
export type AvatarSize = (typeof AVATAR_SIZES)[number];
|
|
21
|
+
|
|
22
|
+
type Props = ExtendProps<
|
|
23
|
+
typeof BaseAvatar.Root,
|
|
24
|
+
{
|
|
25
|
+
alt?: string | undefined;
|
|
26
|
+
children?: undefined;
|
|
27
|
+
name: string;
|
|
28
|
+
size?: AvatarSize | undefined;
|
|
29
|
+
src?: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
export const Avatar = ({ name, size = "md", src, alt, ...props }: Props) => {
|
|
34
|
+
const [status, setStatus] = useState<ImageLoadingStatus>("idle");
|
|
35
|
+
|
|
36
|
+
const fallbackVisible = src === undefined || status === "error";
|
|
37
|
+
return (
|
|
38
|
+
<BaseAvatar.Root {...props} className={rootStyles({ size })}>
|
|
39
|
+
<span
|
|
40
|
+
aria-hidden
|
|
41
|
+
className={skeletonStyles({ loading: status === "loading" })}
|
|
42
|
+
/>
|
|
43
|
+
{src !== undefined && (
|
|
44
|
+
// `key={src}` so the Image remounts when `src` changes, re-issuing
|
|
45
|
+
// `onLoadingStatusChange` from "idle" → "loading" → final. Without
|
|
46
|
+
// the key, a previously-loaded Image stays mounted and `status`
|
|
47
|
+
// stays at its prior value through the transition, briefly mismatching
|
|
48
|
+
// the new src's actual load state.
|
|
49
|
+
<BaseAvatar.Image
|
|
50
|
+
alt={alt ?? name}
|
|
51
|
+
className={imageStyles}
|
|
52
|
+
key={src}
|
|
53
|
+
onLoadingStatusChange={setStatus}
|
|
54
|
+
src={src}
|
|
55
|
+
/>
|
|
56
|
+
)}
|
|
57
|
+
<BaseAvatar.Fallback
|
|
58
|
+
className={fallbackStyles({ visible: fallbackVisible })}
|
|
59
|
+
style={{ backgroundImage: getGradient(name) }}
|
|
60
|
+
>
|
|
61
|
+
{getInitials(name)}
|
|
62
|
+
</BaseAvatar.Fallback>
|
|
63
|
+
</BaseAvatar.Root>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const skeletonStyles = cva({
|
|
68
|
+
base: {
|
|
69
|
+
backgroundColor: "skeleton",
|
|
70
|
+
inset: 0,
|
|
71
|
+
position: "absolute",
|
|
72
|
+
},
|
|
73
|
+
variants: {
|
|
74
|
+
loading: {
|
|
75
|
+
false: { animation: "none" },
|
|
76
|
+
true: { animation: "pulse {durations.pulse} {easings.in-out} infinite" },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const imageStyles = css({
|
|
82
|
+
inset: 0,
|
|
83
|
+
objectFit: "cover",
|
|
84
|
+
opacity: { _starting: 0, base: 1 },
|
|
85
|
+
position: "absolute",
|
|
86
|
+
transition: "opacity {durations.slowest} {easings.out}",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const fallbackStyles = cva({
|
|
90
|
+
base: center.raw({
|
|
91
|
+
inset: 0,
|
|
92
|
+
position: "absolute",
|
|
93
|
+
transition: "opacity {durations.slowest} {easings.out}",
|
|
94
|
+
}),
|
|
95
|
+
variants: {
|
|
96
|
+
visible: {
|
|
97
|
+
false: { opacity: 0 },
|
|
98
|
+
true: { opacity: 1 },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const rootStyles = cva({
|
|
104
|
+
base: {
|
|
105
|
+
color: "background",
|
|
106
|
+
fontWeight: "bold",
|
|
107
|
+
letterSpacing: "wide",
|
|
108
|
+
overflow: "hidden",
|
|
109
|
+
position: "relative",
|
|
110
|
+
textTransform: "uppercase",
|
|
111
|
+
},
|
|
112
|
+
variants: {
|
|
113
|
+
// biome-ignore assist/source/useSortedKeys: The sort order is useful here
|
|
114
|
+
size: {
|
|
115
|
+
"2xs": circle.raw({ fontSize: "xs", size: 6 }),
|
|
116
|
+
xs: circle.raw({ fontSize: "sm", size: 8 }),
|
|
117
|
+
sm: circle.raw({ fontSize: "lg", size: 12 }),
|
|
118
|
+
md: circle.raw({ fontSize: "2xl", size: 16 }),
|
|
119
|
+
lg: circle.raw({ fontSize: "3xl", size: 20 }),
|
|
120
|
+
xl: circle.raw({ fontSize: "4xl", size: 24 }),
|
|
121
|
+
"2xl": circle.raw({ fontSize: "5xl", size: 28 }),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const getGradient = (name: string): string => {
|
|
2
|
+
const normalized = name.trim().toLowerCase();
|
|
3
|
+
const seed = hashString(normalized === "" ? "?" : normalized);
|
|
4
|
+
const hueA = seed % 360;
|
|
5
|
+
const hueB = (hueA + 30 + ((seed >>> 8) % 60)) % 360;
|
|
6
|
+
const hueC = (hueA + 180 + ((seed >>> 16) % 40)) % 360;
|
|
7
|
+
return `linear-gradient(135deg, hsl(${hueA}, 95%, 72%) 0%, hsl(${hueB}, 90%, 68%) 55%, hsl(${hueC}, 95%, 70%) 100%)`;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const hashString = (str: string): number => {
|
|
11
|
+
let h = 2_166_136_261;
|
|
12
|
+
for (let i = 0; i < str.length; i++) {
|
|
13
|
+
h ^= str.charCodeAt(i);
|
|
14
|
+
h = Math.imul(h, 16_777_619);
|
|
15
|
+
}
|
|
16
|
+
return h >>> 0;
|
|
17
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const getInitials = (name: string): string => {
|
|
2
|
+
const parts = name
|
|
3
|
+
.trim()
|
|
4
|
+
.split(/\s+/)
|
|
5
|
+
.filter((part) => part !== "");
|
|
6
|
+
const first = parts[0];
|
|
7
|
+
if (first === undefined) {
|
|
8
|
+
return "?";
|
|
9
|
+
} else {
|
|
10
|
+
const last = parts[parts.length - 1];
|
|
11
|
+
const firstChar = ([...first][0] ?? "").toUpperCase();
|
|
12
|
+
if (last === undefined || last === first) {
|
|
13
|
+
return firstChar;
|
|
14
|
+
} else {
|
|
15
|
+
const lastChar = ([...last][0] ?? "").toUpperCase();
|
|
16
|
+
return firstChar + lastChar;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
};
|