diff --git a/client/.gitignore b/client/.gitignore index f267a8e..1da0c89 100755 --- a/client/.gitignore +++ b/client/.gitignore @@ -4,11 +4,9 @@ node_modules # Source files src/lib -src/framework # Public public/js public/css public/static/config.js public/static/service-worker-assets.js -public/audio diff --git a/client/package.json b/client/package.json index e97f182..3be211a 100755 --- a/client/package.json +++ b/client/package.json @@ -3,7 +3,6 @@ "update": "npm update && run-p install:*", "install:pkg": "node ./build/bundle.js", "install:brixi": "brixi && mv ./brixi/brixi.css ./src/styles/brixi.css && rmdir ./brixi", - "install:ui": "install-ui --framework=./src/framework --audio=./public/audio", "build": "run-p build:*", "build:js": "twist --src=./src --outDir=./public/js --type=esbuild", "build:css": "cssmonster", diff --git a/client/public/audio/activate.wav b/client/public/audio/activate.wav new file mode 100644 index 0000000..6ae8c65 Binary files /dev/null and b/client/public/audio/activate.wav differ diff --git a/client/public/audio/camera.wav b/client/public/audio/camera.wav new file mode 100644 index 0000000..4c191cb Binary files /dev/null and b/client/public/audio/camera.wav differ diff --git a/client/public/audio/deactivate.wav b/client/public/audio/deactivate.wav new file mode 100644 index 0000000..4168e5b Binary files /dev/null and b/client/public/audio/deactivate.wav differ diff --git a/client/public/audio/error-alert.wav b/client/public/audio/error-alert.wav new file mode 100644 index 0000000..10f65be Binary files /dev/null and b/client/public/audio/error-alert.wav differ diff --git a/client/public/audio/error.wav b/client/public/audio/error.wav new file mode 100644 index 0000000..97fda24 Binary files /dev/null and b/client/public/audio/error.wav differ diff --git a/client/public/audio/mouseclick.wav b/client/public/audio/mouseclick.wav new file mode 100644 index 0000000..96e5268 Binary files /dev/null and b/client/public/audio/mouseclick.wav differ diff --git a/client/public/audio/mouseover.wav b/client/public/audio/mouseover.wav new file mode 100644 index 0000000..887f381 Binary files /dev/null and b/client/public/audio/mouseover.wav differ diff --git a/client/public/audio/notification.wav b/client/public/audio/notification.wav new file mode 100644 index 0000000..7b41086 Binary files /dev/null and b/client/public/audio/notification.wav differ diff --git a/client/public/audio/snackbar.wav b/client/public/audio/snackbar.wav new file mode 100644 index 0000000..1aac787 Binary files /dev/null and b/client/public/audio/snackbar.wav differ diff --git a/client/public/audio/success.wav b/client/public/audio/success.wav new file mode 100644 index 0000000..2e8b6d1 Binary files /dev/null and b/client/public/audio/success.wav differ diff --git a/client/public/audio/warning.wav b/client/public/audio/warning.wav new file mode 100644 index 0000000..b0a52df Binary files /dev/null and b/client/public/audio/warning.wav differ diff --git a/client/src/components/window/window.ts b/client/src/components/window/window.ts index 04f43aa..ef7a282 100644 --- a/client/src/components/window/window.ts +++ b/client/src/components/window/window.ts @@ -191,6 +191,10 @@ export default class Window extends SuperComponent{ let diffY = bounds.y - y - this.localY; this.x -= diffX; this.y -= diffY; + if (this.y < 28) this.y = 28; + if (this.y > window.innerHeight - 28) this.y = window.innerHeight - 28; + if (this.x < 0) this.x = 0; + if (this.x > window.innerWidth - bounds.width) this.x = window.innerWidth - bounds.width; this.style.transform = `translate(${this.x}px, ${this.y}px)`; } } diff --git a/client/src/components/window/windows/dice-box/dice-box.ts b/client/src/components/window/windows/dice-box/dice-box.ts index aa4ce7d..d6b43f8 100644 --- a/client/src/components/window/windows/dice-box/dice-box.ts +++ b/client/src/components/window/windows/dice-box/dice-box.ts @@ -64,6 +64,7 @@ export default class DiceBox extends SuperComponent{ } private handleKeypress:EventListener = (e:Event) => { + e.stopImmediatePropagation() if (e instanceof KeyboardEvent){ if (e.key.toLowerCase() === "enter"){ this.doRoll(); @@ -71,6 +72,10 @@ export default class DiceBox extends SuperComponent{ } } + private noopEvent:EventListener = (e:Event) => { + e.stopImmediatePropagation(); + } + override async render() { const view = html` @@ -81,7 +86,7 @@ export default class DiceBox extends SuperComponent{ })}
- +

Example: 1d20 + 1d6 + 4

`; diff --git a/client/src/components/window/windows/fog-brush/fog-brush.scss b/client/src/components/window/windows/fog-brush/fog-brush.scss index 4b6aa30..91778ab 100644 --- a/client/src/components/window/windows/fog-brush/fog-brush.scss +++ b/client/src/components/window/windows/fog-brush/fog-brush.scss @@ -13,3 +13,32 @@ fog-brush-circle{ pointer-events: none; cursor: crosshair; } + +rect-preview{ + display: inline-block; + position: fixed; + border: 1px solid yellow; + pointer-events: none; + z-index: 1000; + transform-origin: top left; + + &.hidden { + opacity: 0; + visibility: hidden; + } +} +.poly-preview{ + display: inline-block; + position: fixed; + pointer-events: none; + z-index: 1000; + width: 100vw; + height: 100vh; + top: 0; + left: 0; + + &.hidden { + opacity: 0; + visibility: hidden; + } +} diff --git a/client/src/components/window/windows/fog-brush/fog-brush.ts b/client/src/components/window/windows/fog-brush/fog-brush.ts index 05a0954..f1bbcad 100644 --- a/client/src/components/window/windows/fog-brush/fog-brush.ts +++ b/client/src/components/window/windows/fog-brush/fog-brush.ts @@ -6,19 +6,30 @@ import { publish } from "@codewithkyle/pubsub"; import room from "room"; import TabletopPage from "pages/tabletop-page/tabletop-page"; +type Point = { + x: number, + y: number, +} interface IFogBrush { } -export default class FogBrush extends SuperComponent{ +export default class FogBrush extends SuperComponent { private painting: boolean; - private mode: "eraser" | "fill"; - private brushSize: number; - private fogBrushCircle: HTMLElement; private tabletop: TabletopPage; + private mode: "rect" | "poly"; + private points: Array; + private rectPreviewEl: HTMLElement; + private polyPreviewEl: SVGElement; + private mouse: Point; constructor() { super(); this.painting = false; - this.mode = "eraser"; - this.brushSize = 2; + this.mode = "rect"; + this.points = []; + this.mouse = { x: 0, y: 0 }; + this.rectPreviewEl = document.createElement("rect-preview"); + this.polyPreviewEl = document.createElementNS('http://www.w3.org/2000/svg', "svg"); + this.polyPreviewEl.classList.add("poly-preview"); + this.polyPreviewEl.setAttribute("viewBox", `0 0, ${window.innerWidth} ${window.innerHeight}`); } override async connected() { @@ -27,34 +38,94 @@ export default class FogBrush extends SuperComponent{ this.tabletop.addEventListener("mousedown", this.onMouseDown); this.tabletop.addEventListener("mouseup", this.onMouseUp); this.tabletop.addEventListener("mousemove", this.onMouseMove); - window.addEventListener("wheel", this.onMouseWheel, { passive: true }); + window.addEventListener("keydown", this.onKeyDown); this.render(); - this.fogBrushCircle = document.createElement("fog-brush-circle"); - document.body.appendChild(this.fogBrushCircle); - this.scaleBrush(); publish("tabletop", "cursor:draw"); } disconnected(): void { - if (this.fogBrushCircle) { - this.fogBrushCircle.remove(); - } this.tabletop.removeEventListener("mousedown", this.onMouseDown); this.tabletop.removeEventListener("mouseup", this.onMouseUp); this.tabletop.removeEventListener("mousemove", this.onMouseMove); - window.removeEventListener("wheel", this.onMouseWheel); + window.removeEventListener("keydown", this.onKeyDown); + this.rectPreviewEl.remove(); + this.polyPreviewEl.remove(); publish("tabletop", "cursor:move"); } - private scaleBrush() { - if (this.fogBrushCircle) { - if (this.brushSize > 2) { - this.fogBrushCircle.style.width = `${(this.brushSize - 2) * room.gridSize}px`; - this.fogBrushCircle.style.height = `${(this.brushSize - 2) * room.gridSize}px`; - } else { - this.fogBrushCircle.style.width = `${(this.brushSize - 1) * room.gridSize}px`; - this.fogBrushCircle.style.height = `${(this.brushSize - 1) * room.gridSize}px`; + private updateRectPreview(x: number, y: number) { + if (!this.painting) { + this.rectPreviewEl.classList.add("hidden"); + } + if (this.mode === "rect") { + if (!this.rectPreviewEl?.isConnected) { + document.body.appendChild(this.rectPreviewEl); + } + this.rectPreviewEl.classList.remove("hidden"); + const width = Math.abs(x - this.points[0].x); + const height = Math.abs(y - this.points[0].y); + this.rectPreviewEl.style.top = `${this.points[0].y}px`; + this.rectPreviewEl.style.left = `${this.points[0].x}px`; + this.rectPreviewEl.style.width = `${width}px`; + this.rectPreviewEl.style.height = `${height}px`; + let transform = ""; + if (x > this.points[0].x && y < this.points[0].y) { + transform += `scaleY(-1) `; + } + if (x < this.points[0].x) { + transform = `scaleX(-1) `; + if (y < this.points[0].y) { + transform += `scaleY(-1) `; + } + } + this.rectPreviewEl.style.transform = transform; + } + } + + private updatePolyPreview() { + if (!this.painting) { + this.polyPreviewEl.classList.add("hidden"); + } + if (this.mode === "poly") { + if (!this.polyPreviewEl?.isConnected) { + document.body.appendChild(this.polyPreviewEl); + } + this.polyPreviewEl.innerHTML = ""; + this.polyPreviewEl.classList.remove("hidden"); + for (let i = 0; i < this.points.length - 1; i++) { + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', this.points[i].x.toString()); + line.setAttribute('y1', this.points[i].y.toString()); + line.setAttribute('x2', this.points[i + 1].x.toString()); + line.setAttribute('y2', this.points[i + 1].y.toString()); + line.setAttribute('stroke', 'yellow'); + line.setAttribute('stroke-width', "1"); + this.polyPreviewEl.appendChild(line); + } + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', this.points[this.points.length-1].x.toString()); + line.setAttribute('y1', this.points[this.points.length-1].y.toString()); + line.setAttribute('x2', this.mouse.x.toString()); + line.setAttribute('y2', this.mouse.y.toString()); + line.setAttribute('stroke', 'yellow'); + line.setAttribute('stroke-width', "1"); + this.polyPreviewEl.appendChild(line); + console.log("line", line); + } + } + + private onKeyDown = (e: KeyboardEvent) => { + const key = e.key.toLowerCase(); + if (key === "enter" && this.painting && this.mode === "poly"){ + this.polyPreviewEl.classList.add("hidden"); + if (this.points.length >= 2) { + publish("fog", { + type: "poly", + points: this.points, + }); } + this.points = []; + this.painting = false; } } @@ -63,80 +134,60 @@ export default class FogBrush extends SuperComponent{ this.painting = true; const x = e.clientX; const y = e.clientY; - publish("fog", { - type: this.mode, - data: { - x: x, - y: y, - brushSize: this.brushSize, - }, - }); + this.points.push({ x, y }); + if (this.mode === "poly") { + this.updatePolyPreview(); + } } } private onMouseUp = (e: MouseEvent) => { - this.painting = false; - } - - private onMouseWheel = (e: WheelEvent) => { - if (this.fogBrushCircle) { - let zoom = 1; - if (sessionStorage.getItem("zoom")) { - zoom = parseFloat(sessionStorage.getItem("zoom")); - } - if (this.brushSize > 2) { - this.fogBrushCircle.style.transform = `matrix(${zoom}, 0, 0, ${zoom}, ${e.clientX - (room.gridSize * (this.brushSize - 2) * 0.5)}, ${e.clientY - (room.gridSize * (this.brushSize - 2) * 0.5)})`; - } else { - this.fogBrushCircle.style.transform = `matrix(${zoom}, 0, 0, ${zoom}, ${e.clientX - (room.gridSize * (this.brushSize - 1) * 0.5)}, ${e.clientY - (room.gridSize * (this.brushSize - 1) * 0.5)})`; + const x = e.clientX; + const y = e.clientY; + if (this.painting) { + if (this.mode === "rect") { + this.rectPreviewEl.classList.add("hidden"); + this.points.push({ x, y }); + if (this.points.length === 2) { + publish("fog", { + type: "rect", + points: this.points, + }); + } + this.points = []; + this.painting = false; } } } private onMouseMove = (e: MouseEvent) => { - if (this.fogBrushCircle) { - let zoom = 1; - if (sessionStorage.getItem("zoom")) { - zoom = parseFloat(sessionStorage.getItem("zoom")); - } - if (this.brushSize > 2) { - this.fogBrushCircle.style.transform = `matrix(${zoom}, 0, 0, ${zoom}, ${e.clientX - (room.gridSize * (this.brushSize - 2) * 0.5)}, ${e.clientY - (room.gridSize * (this.brushSize - 2) * 0.5)})`; - } else { - this.fogBrushCircle.style.transform = `matrix(${zoom}, 0, 0, ${zoom}, ${e.clientX - (room.gridSize * (this.brushSize - 1) * 0.5)}, ${e.clientY - (room.gridSize * (this.brushSize - 1) * 0.5)})`; - } - } + const x = e.clientX; + const y = e.clientY; + this.mouse = { x, y }; if (this.painting) { - const x = e.clientX; - const y = e.clientY; - publish("fog", { - type: this.mode, - data: { - x: x, - y: y, - brushSize: this.brushSize, - }, - }); + switch(this.mode){ + case "rect": + this.updateRectPreview(x, y); + break; + case "poly": + this.updatePolyPreview(); + break; + } } } render() { const view = html` { - this.mode = e.detail.id; - }} + this.mode = e.detail.id; + this.render(); + }} > - { - this.brushSize = parseInt(e.detail.value); - this.scaleBrush(); - }} - > + ${this.mode === "poly" ? html`

Press 'enter' to complete path.

` : ""} `; render(view, this); } diff --git a/client/src/components/window/windows/monster-editor/monster-info-table.ts b/client/src/components/window/windows/monster-editor/monster-info-table.ts index 006a8aa..58ba9b3 100644 --- a/client/src/components/window/windows/monster-editor/monster-info-table.ts +++ b/client/src/components/window/windows/monster-editor/monster-info-table.ts @@ -71,6 +71,10 @@ class MonsterInfoTable extends SuperComponent{ this.set(updatedModal, true); } + private noopEvent: EventListener = (e:Event) => { + e.stopImmediatePropagation(); + } + override render(): void { const view = html`

${unsafeHTML(this.model.label)}

@@ -78,12 +82,12 @@ class MonsterInfoTable extends SuperComponent{ return html` - + - + `; })} diff --git a/client/src/controllers/ws.ts b/client/src/controllers/ws.ts index d5c63f0..0cf21b0 100644 --- a/client/src/controllers/ws.ts +++ b/client/src/controllers/ws.ts @@ -5,7 +5,7 @@ let socket:WebSocket; let connected = false; let wasReconnection = false; -let SOCKET_URL:string = "ws://ws.tabletopper.local"; +let SOCKET_URL:string = "ws://localhost:8080"; if (location.host == "tabletopper.app") { SOCKET_URL = "wss://ws.tabletopper.app"; } diff --git a/client/src/framework/component.ts b/client/src/framework/component.ts new file mode 100644 index 0000000..14951d0 --- /dev/null +++ b/client/src/framework/component.ts @@ -0,0 +1,21 @@ +import SuperComponent from "@codewithkyle/supercomponent"; + +export default class Component extends SuperComponent { + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + if (name.indexOf("data-") === 0) { + name = name.substring(5); + // @ts-ignore + if (name in this.model) { + let value: any; + try { + value = JSON.parse(newValue); + } catch (e) { + value = newValue; + } + const updated = this.get(); + updated[name] = value; + this.set(updated); + } + } + } +} diff --git a/client/src/framework/components/accordion/accordion.scss b/client/src/framework/components/accordion/accordion.scss new file mode 100644 index 0000000..1b2a5f2 --- /dev/null +++ b/client/src/framework/components/accordion/accordion.scss @@ -0,0 +1,158 @@ +accordion-component { + display: block; + position: relative; + width: 100%; + + .section { + display: block; + width: 100%; + position: relative; + + &:first-child { + label { + border-radius: 0.5rem 0.5rem 0 0; + border-width: 1px; + } + } + + &:last-child { + label { + border-radius: 0 0 0.5rem 0.5rem; + } + + .content { + border-radius: 0 0 0.5rem 0.5rem; + } + + input:checked + label { + border-radius: 0; + } + } + + &:only-child { + label { + border-radius: 0.5rem; + } + + .content { + border-radius: 0 0 0.5rem 0.5rem; + } + + input:checked + label { + border-radius: 0.5rem 0.5rem 0 0; + } + } + + input { + position: absolute; + top: 0; + left: 0; + opacity: 0; + visibility: hidden; + + &:checked { + & + label { + i { + svg { + transform: rotate(90deg); + } + } + } + + & ~ .content { + display: block; + } + } + } + + label { + display: flex; + align-items: center; + flex-flow: row nowrap; + justify-content: space-between; + height: 48px; + transition: all 80ms var(--ease-in-out); + padding: 0 1rem; + cursor: pointer; + border-style: solid; + border-color: var(--grey-300); + border-width: 0 1px 1px 1px; + + &:hover, + &:focus-visible { + background-color: var(--grey-50); + + i { + svg { + color: var(--grey-700); + } + } + } + + &:active { + background-color: var(--grey-100); + } + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-600); + + &:hover, + &:focus-visible { + background-color: hsl(var(--grey-400-hsl) / 0.05); + + i { + svg { + color: var(--grey-500); + } + } + } + + &:active { + background-color: hsl(var(--grey-400-hsl) / 0.1); + } + } + + span { + display: inline-block; + font-size: var(--font-md); + font-weight: var(--font-medium); + flex: 1; + width: 100%; + user-select: none; + } + + i { + width: 24px; + height: 24px; + display: inline-flex; + justify-content: center; + align-items: center; + + svg { + transition: all 150ms var(--ease-in-out); + transform: rotate(0); + color: var(--grey-600); + width: 18px; + height: 18px; + } + } + } + + .content { + font-size: var(--font-sm); + display: none; + width: 100%; + line-height: 1.618; + color: var(--grey-700); + padding: 0.75rem 1rem; + border-style: solid; + border-color: var(--grey-300); + border-width: 0 1px 1px 1px; + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-600); + color: var(--grey-300); + } + } + } +} diff --git a/client/src/framework/components/accordion/accordion.ts b/client/src/framework/components/accordion/accordion.ts new file mode 100644 index 0000000..7ba3b29 --- /dev/null +++ b/client/src/framework/components/accordion/accordion.ts @@ -0,0 +1,57 @@ +import { UUID } from "@codewithkyle/uuid"; +import { html, render } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import Component from "~brixi/component"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; + +env.css(["accordion"]); + +export interface AccordionSection { + label: string; + content: string; +} +export interface IAccordion { + sections: Array; +} +export default class Accordion extends Component { + constructor() { + super(); + this.model = { + sections: [], + }; + } + + static get observedAttributes() { + return ["data-sections"]; + } + + override connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + private renderSection(section: AccordionSection) { + const name = UUID(); + return html` +
+ + +
${unsafeHTML(decodeURI(section.content))}
+
+ `; + } + + override render() { + const view = html` ${this.model.sections.map(this.renderSection)} `; + render(view, this); + } +} +env.bind("accordion-component", Accordion); diff --git a/client/src/framework/components/accordion/index.html b/client/src/framework/components/accordion/index.html new file mode 100644 index 0000000..0944ec9 --- /dev/null +++ b/client/src/framework/components/accordion/index.html @@ -0,0 +1,5 @@ + + + diff --git a/client/src/framework/components/accordion/readme.md b/client/src/framework/components/accordion/readme.md new file mode 100644 index 0000000..14cef88 --- /dev/null +++ b/client/src/framework/components/accordion/readme.md @@ -0,0 +1,34 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| sections | AccordionSection[] | ✅ | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Accordion Section + +```typescript +interface AccordionSection { + label: string; + content: string; +} +``` + +### HTML Content + +You can render HTML content within a section by using the `encodeURI()` function. [Learn more about URI encoding on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI). + +```javascript +html` + Learn more on MDN.')}"}]' + > +` +``` diff --git a/client/src/framework/components/accordion/static.html b/client/src/framework/components/accordion/static.html new file mode 100644 index 0000000..2e6773b --- /dev/null +++ b/client/src/framework/components/accordion/static.html @@ -0,0 +1 @@ +

Coming soon.

diff --git a/client/src/framework/components/alert/alert.scss b/client/src/framework/components/alert/alert.scss new file mode 100644 index 0000000..a4dcb68 --- /dev/null +++ b/client/src/framework/components/alert/alert.scss @@ -0,0 +1,166 @@ +alert-component { + display: flex; + flex-flow: row nowrap; + width: 100%; + position: relative; + border-radius: 1rem; + padding: 1rem; + + &[flex="items-center"] { + .close { + position: relative; + top: auto; + right: 0; + order: 3; + margin-left: auto; + } + + .copy { + padding-right: 1rem; + } + } + + &[kind="warning"] { + background-color: hsl(var(--warning-500-hsl) / 0.05); + color: var(--warning-700); + + h3 { + color: var(--warning-800); + } + + @media (prefers-color-scheme: dark) { + color: var(--warning-300); + background-color: hsl(var(--warning-300-hsl) / 0.05); + + h3 { + color: var(--warning-300); + } + } + } + + &[kind="danger"] { + background-color: hsl(var(--danger-500-hsl) / 0.05); + color: var(--danger-700); + + h3 { + color: var(--danger-800); + } + + @media (prefers-color-scheme: dark) { + color: var(--danger-300); + background-color: hsl(var(--danger-400-hsl) / 0.05); + + h3 { + color: var(--danger-300); + } + } + } + + &[kind="success"] { + background-color: hsl(var(--success-500-hsl) / 0.05); + color: var(--success-700); + + h3 { + color: var(--success-800); + } + + @media (prefers-color-scheme: dark) { + color: var(--success-300); + background-color: hsl(var(--success-400-hsl) / 0.05); + + h3 { + color: var(--success-300); + } + } + } + + &[kind="info"] { + background-color: hsl(var(--primary-500-hsl) / 0.05); + color: var(--primary-700); + + h3 { + color: var(--primary-800); + } + + @media (prefers-color-scheme: dark) { + color: var(--primary-300); + background-color: hsl(var(--primary-400-hsl) / 0.05); + + h3 { + color: var(--primary-300); + } + } + } + + h3 { + display: block; + font-weight: var(--font-medium); + line-height: 1.618; + + &:not(:last-child) { + margin-bottom: 0.25rem; + } + } + + .copy { + flex: 1; + } + + p { + display: block; + line-height: 1.375; + font-size: var(--font-sm); + + &:not(:last-child) { + margin-bottom: 0.25rem; + } + } + + ul { + padding-left: 0.5rem; + + &:not(:last-child) { + margin-bottom: 0.25rem; + } + + li { + font-size: var(--font-sm); + line-height: 1.375; + list-style-type: disc; + } + } + + i { + width: 24px; + height: 24px; + display: inline-flex; + justify-content: center; + align-items: center; + margin-right: 0.75rem; + + svg { + width: 20px; + height: 20px; + } + } + + .actions { + display: flex; + flex-flow: row nowrap; + align-items: center; + width: 100%; + margin-left: -0.5rem; + margin-top: 0.5rem; + + button { + margin-right: 0.5rem; + } + } + + .close { + position: absolute; + top: 0.25rem; + right: 0.25rem; + z-index: 10; + } +} diff --git a/client/src/framework/components/alert/alert.ts b/client/src/framework/components/alert/alert.ts new file mode 100644 index 0000000..e446e6f --- /dev/null +++ b/client/src/framework/components/alert/alert.ts @@ -0,0 +1,170 @@ +import { html, render, TemplateResult } from "lit-html"; +import env from "~brixi/controllers/env"; +import "~brixi/components/buttons/button/button"; +import { parseDataset } from "~brixi/utils/general"; +import Component from "~brixi/component"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; + +env.css(["alert", "button"]); + +export interface ActionItem { + label: string; + id: string; +} +export interface IAlert { + type: "warning" | "info" | "danger" | "success"; + heading: string; + description: string; + list: Array; + closeable: boolean; + actions: Array; +} +export default class Alert extends Component { + constructor() { + super(); + this.model = { + type: "info", + heading: null, + description: null, + list: [], + closeable: false, + actions: [], + }; + } + + static get observedAttributes() { + return ["data-type", "data-heading", "data-description", "data-list", "data-closeable", "data-actions"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + private renderIcon() { + switch (this.model.type) { + case "danger": + return html` + + `; + case "info": + return html` + + `; + case "success": + return html` + + `; + case "warning": + return html` + + `; + } + } + + private handleClose: EventListener = () => { + const event = new CustomEvent("close", { bubbles: true, cancelable: true }); + this.dispatchEvent(event); + this.remove(); + }; + + private handleActionClick: EventListener = (e: Event) => { + const event = new CustomEvent("action", { + detail: { + // @ts-ignore + id: e.currentTarget.dataset.id ?? null, + }, + bubbles: true, + cancelable: true, + }); + this.dispatchEvent(event); + }; + + private renderCloseButton(): string | TemplateResult { + let out: string | TemplateResult; + if (this.model.closeable) { + out = html` + + `; + } else { + out = ""; + } + return out; + } + + private renderList(): string | TemplateResult { + let out: string | TemplateResult; + if (this.model.list.length) { + out = html` +
    + ${this.model.list.map((item) => { + return html`
  • ${unsafeHTML(decodeURI(item))}
  • `; + })} +
+ `; + } else { + out = ""; + } + return out; + } + + private renderActions(): string | TemplateResult { + let out: string | TemplateResult; + if (this.model.actions.length) { + out = html` +
+ ${this.model.actions.map((bttn) => { + return html` + + `; + })} +
+ `; + } else { + out = ""; + } + return out; + } + + override render() { + const view = html` + ${this.renderCloseButton()} + ${this.renderIcon()} +
+ ${this.model.heading ? html`

${this.model.heading}

` : ""} ${this.model.description ? html`

${unsafeHTML(decodeURI(this.model.description))}

` : ""} + ${this.renderList()} ${this.renderActions()} +
+ `; + this.setAttribute("kind", this.model.type); + if (!this.model.heading && !this.model.list.length) { + this.setAttribute("flex", "items-center"); + } + render(view, this); + } +} +env.bind("alert-component", Alert); diff --git a/client/src/framework/components/alert/index.html b/client/src/framework/components/alert/index.html new file mode 100644 index 0000000..4d4465f --- /dev/null +++ b/client/src/framework/components/alert/index.html @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/client/src/framework/components/alert/readme.md b/client/src/framework/components/alert/readme.md new file mode 100644 index 0000000..f8d3889 --- /dev/null +++ b/client/src/framework/components/alert/readme.md @@ -0,0 +1,51 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| type | AlertType | ✅ | +| heading | string | | +| description | string | | +| list | string[] | | +| closeable | boolean | | +| actions | ActionItem[] | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Alert Type + +```typescript +type AlertType = "warning" | "info" | "danger" | "success"; +``` + +### Action Item + +```typescript +interface ActionItem { + label: string; + id: string; +} +``` + +### HTML Content + +You can render HTML content within a section by using the `encodeURI()` function. [Learn more about URI encoding on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI). + +```javascript +html` + +` +``` diff --git a/client/src/framework/components/alert/static.html b/client/src/framework/components/alert/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/badges/badge/badge.scss b/client/src/framework/components/badges/badge/badge.scss new file mode 100644 index 0000000..abb6461 --- /dev/null +++ b/client/src/framework/components/badges/badge/badge.scss @@ -0,0 +1,79 @@ +badge-component { + position: absolute; + top: -6px; + right: -6px; + min-width: 12px; + min-height: 12px; + display: inline-block; + opacity: 0; + animation: badgeFadeIn 150ms 150ms forwards var(--ease-in); + + &.-text { + width: 24px; + height: 24px; + top: -12px; + right: -12px; + font-size: 10px; + color: var(--white); + text-align: center; + line-height: 24px; + font-style: normal; + font-weight: var(--font-medium); + + span { + z-index: 3; + } + } + + &::after, + &::before { + content: ""; + position: absolute; + user-select: none; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 50%; + background-color: var(--danger-400); + } + + &::after { + animation: badgeGlow 1s var(--ease-in-out) infinite; + } + + &::before { + opacity: 0.87; + transform: scale(0); + animation: badgePing 1s cubic-bezier(0, 0, 0.2, 1) infinite; + } +} +@keyframes badgePing { + 75%, + 100% { + transform: scale(2); + opacity: 0; + } +} +@keyframes badgeGlow { + 0% { + background-color: var(--danger-500); + box-shadow: 0 0 0px var(--danger-50); + } + 50% { + background-color: var(--danger-400); + box-shadow: 0 0 4px var(--danger-50); + } + 100% { + background-color: var(--danger-500); + box-shadow: 0 0 0px var(--danger-50); + } +} +@keyframes badgeFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/client/src/framework/components/badges/badge/badge.ts b/client/src/framework/components/badges/badge/badge.ts new file mode 100644 index 0000000..43d7966 --- /dev/null +++ b/client/src/framework/components/badges/badge/badge.ts @@ -0,0 +1,48 @@ +import { html, render } from "lit-html"; +import Component from "~brixi/component"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; + +env.css(["badge"]); + +export interface IBadge { + value: number; + offsetX: number; + offsetY: number; +} +export default class Badge extends Component { + constructor() { + super(); + this.model = { + value: null, + offsetX: 0, + offsetY: 0, + }; + } + + static get observedAttributes() { + return ["data-value", "data-offset-x", "data-offset-y"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + override render() { + this.style.transform = `translate(${this.model.offsetX}px, ${this.model.offsetY}px)`; + const hasValue = this.model.value !== null && this.model.value?.toString() !== ""; + if (hasValue) { + this.classList.add("-text"); + } else { + this.classList.remove("-text"); + } + let value: string | number = this.model.value; + if (value > 9) { + value = "9+"; + } + const view = html` ${hasValue ? html`${value}` : ""} `; + render(view, this); + } +} +env.bind("badge-component", Badge); diff --git a/client/src/framework/components/badges/badge/index.html b/client/src/framework/components/badges/badge/index.html new file mode 100644 index 0000000..42aa95b --- /dev/null +++ b/client/src/framework/components/badges/badge/index.html @@ -0,0 +1,7 @@ + + + + diff --git a/client/src/framework/components/badges/badge/readme.md b/client/src/framework/components/badges/badge/readme.md new file mode 100644 index 0000000..938c7fe --- /dev/null +++ b/client/src/framework/components/badges/badge/readme.md @@ -0,0 +1,18 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| value | number | | +| offsetX | number | | +| offsetY | number | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + diff --git a/client/src/framework/components/badges/badge/static.html b/client/src/framework/components/badges/badge/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/badges/status-badge/index.html b/client/src/framework/components/badges/status-badge/index.html new file mode 100644 index 0000000..bc6cd37 --- /dev/null +++ b/client/src/framework/components/badges/status-badge/index.html @@ -0,0 +1,35 @@ +
+ + + + + + + + + +
+ + diff --git a/client/src/framework/components/badges/status-badge/readme.md b/client/src/framework/components/badges/status-badge/readme.md new file mode 100644 index 0000000..0e70632 --- /dev/null +++ b/client/src/framework/components/badges/status-badge/readme.md @@ -0,0 +1,31 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| color | BadgeColor | ✅ | +| label | string | ✅ | +| dot | BadgeDot | | +| icon | string | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Badge Color + +```typescript +type BadgeColor = "grey" | "primary" | "success" | "warning" | "danger"; +``` + +### Badge Dot + +```typescript +type BadgeDot = "right" | "left" | null; +``` diff --git a/client/src/framework/components/badges/status-badge/static.html b/client/src/framework/components/badges/status-badge/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/badges/status-badge/status-badge.scss b/client/src/framework/components/badges/status-badge/status-badge.scss new file mode 100644 index 0000000..7f93f0c --- /dev/null +++ b/client/src/framework/components/badges/status-badge/status-badge.scss @@ -0,0 +1,163 @@ +status-badge { + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + justify-content: center; + height: 24px; + line-height: 24px; + padding: 0 0.75rem; + font-size: var(--font-xs); + font-weight: var(--font-medium); + background-color: hsl(var(--grey-500-hsl) / 0.1); + color: var(--grey-700); + border-radius: 0.75rem; + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--grey-300-hsl) / 0.05); + color: var(--grey-300); + } + + &[color="grey"] { + background-color: hsl(var(--grey-500-hsl) / 0.1); + color: var(--grey-700); + + &[dot] { + &::before { + background-color: var(--grey-400); + } + } + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--grey-300-hsl) / 0.05); + color: var(--grey-400); + + &[dot] { + &::before { + background-color: var(--grey-400); + } + } + } + } + + &[color="primary"] { + background-color: hsl(var(--primary-500-hsl) / 0.1); + color: var(--primary-700); + + &[dot] { + &::before { + background-color: var(--primary-400); + } + } + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--primary-300-hsl) / 0.05); + color: var(--primary-400); + + &[dot] { + &::before { + background-color: var(--primary-400); + } + } + } + } + + &[color="danger"] { + background-color: hsl(var(--danger-500-hsl) / 0.1); + color: var(--danger-700); + + &[dot] { + &::before { + background-color: var(--danger-400); + } + } + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--danger-300-hsl) / 0.05); + color: var(--danger-400); + + &[dot] { + &::before { + background-color: var(--danger-400); + } + } + } + } + + &[color="warning"] { + background-color: hsl(var(--warning-500-hsl) / 0.1); + color: var(--warning-700); + + &[dot] { + &::before { + background-color: var(--warning-400); + } + } + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--warning-300-hsl) / 0.05); + color: var(--warning-400); + + &[dot] { + &::before { + background-color: var(--warning-400); + } + } + } + } + + &[color="success"] { + background-color: hsl(var(--success-500-hsl) / 0.1); + color: var(--success-700); + + &[dot] { + &::before { + background-color: var(--success-400); + } + } + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--success-300-hsl) / 0.05); + color: var(--success-400); + + &[dot] { + &::before { + background-color: var(--success-400); + } + } + } + } + + &[dot] { + &::before { + content: ""; + width: 6px; + height: 6px; + display: inline-block; + border-radius: 50%; + position: relative; + } + + &[dot="right"] { + &::before { + order: 2; + margin-left: 0.5rem; + } + } + + &[dot="left"] { + &::before { + order: -1; + margin-right: 0.5rem; + } + } + } + + img, + svg { + width: 18px; + height: 18px; + object-fit: cover; + margin-right: 0.25rem; + margin-left: -0.25rem; + } +} diff --git a/client/src/framework/components/badges/status-badge/status-badge.ts b/client/src/framework/components/badges/status-badge/status-badge.ts new file mode 100644 index 0000000..65e1f9e --- /dev/null +++ b/client/src/framework/components/badges/status-badge/status-badge.ts @@ -0,0 +1,44 @@ +import { html, render } from "lit-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import Component from "~brixi/component"; + +env.css(["status-badge"]); + +export interface IStatusBadge { + color: "grey" | "primary" | "success" | "warning" | "danger"; + label: string; + dot: "right" | "left" | null; + icon: string; +} +export default class StatusBadge extends Component { + constructor() { + super(); + this.model = { + color: "grey", + label: "", + dot: null, + icon: null, + }; + } + + static get observedAttributes() { + return ["data-color", "data-label", "data-dot", "data-icon"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + override render() { + this.setAttribute("color", this.model.color); + if (this.model.dot && !this.model.icon) { + this.setAttribute("dot", this.model.dot); + } + const view = html` ${this.model.icon ? unsafeHTML(this.model.icon) : ""} ${this.model.label} `; + render(view, this); + } +} +env.bind("status-badge", StatusBadge); diff --git a/client/src/framework/components/breadcrumb-trail/breadcrumb-trail.scss b/client/src/framework/components/breadcrumb-trail/breadcrumb-trail.scss new file mode 100644 index 0000000..054c82a --- /dev/null +++ b/client/src/framework/components/breadcrumb-trail/breadcrumb-trail.scss @@ -0,0 +1,181 @@ +breadcrumb-trail { + display: inline-flex; + align-items: center; + flex-flow: row nowrap; + position: relative; + + button { + position: relative; + padding: 0; + margin: 0; + font-size: var(--font-sm); + color: var(--grey-400); + display: inline-flex; + align-items: center; + height: 32px; + transition: all 80ms ease-in-out; + + &:last-child { + color: var(--grey-700); + + i { + color: var(--grey-600); + } + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + + i { + color: var(--grey-400); + } + } + } + + &:hover, + &:focus-visible { + color: var(--primary-700); + + i { + color: var(--primary-600); + } + + @media (prefers-color-scheme: dark) { + color: var(--primary-400); + + i { + color: var(--primary-300); + } + } + } + + @media (min-width: 411px) { + font-size: var(--font-md); + } + + i { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--grey-400); + margin-right: 0.25rem; + transition: all 80ms ease-in-out; + + &:only-child { + margin-right: 0; + } + + svg { + width: 18px; + height: 18px; + + @media (min-width: 411px) { + width: 22px; + height: 22px; + } + } + } + } + + .arrow { + width: 16px; + height: 16px; + color: var(--grey-300); + margin: 0 0.25rem; + + @media (min-width: 411px) { + margin: 0 0.5rem; + } + + @media (prefers-color-scheme: dark) { + color: var(--grey-600); + } + } + + breadcrumb-overflow-menu { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + + & > button { + width: 24px; + height: 24px; + + &:hover, + &:focus-visible { + color: var(--grey-600); + + & + breadcrumb-menu { + opacity: 1; + visibility: visible; + pointer-events: all; + } + } + + svg { + width: 18px; + height: 18px; + color: var(--grey-400); + } + } + + breadcrumb-menu { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + background-color: var(--white); + border-radius: 0.5rem; + padding: 0.5rem 0; + box-shadow: var(--shadow-black-sm); + border: 1px solid var(--grey-300); + opacity: 0; + visibility: hidden; + pointer-events: none; + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-800); + background-color: var(--grey-900); + } + + &:hover, + &:focus-within { + opacity: 1; + visibility: visible; + pointer-events: all; + } + + button { + width: 100%; + white-space: nowrap; + padding: 0 1rem; + height: 32px; + color: var(--grey-400); + font-size: var(--font-base); + + &:last-child { + color: var(--grey-400); + } + + &:hover, + &:focus-visible { + color: var(--primary-700); + + i { + color: var(--primary-600); + } + + @media (prefers-color-scheme: dark) { + color: var(--primary-400); + + i { + color: var(--primary-300); + } + } + } + } + } + } +} diff --git a/client/src/framework/components/breadcrumb-trail/breadcrumb-trail.ts b/client/src/framework/components/breadcrumb-trail/breadcrumb-trail.ts new file mode 100644 index 0000000..c5778d4 --- /dev/null +++ b/client/src/framework/components/breadcrumb-trail/breadcrumb-trail.ts @@ -0,0 +1,144 @@ +import { html, render, TemplateResult } from "lit-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import Component from "~brixi/component"; + +env.css(["breadcrumb-trail"]); + +interface ILink { + label?: string; + icon?: string; + ariaLabel?: string; + id: string; +} +export interface IBreadcrumbTrail { + links: Array; +} +export default class BreadcrumbTrail extends Component { + constructor() { + super(); + this.model = { + links: [], + }; + } + + static get observedAttributes() { + return ["data-links"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + private handleClick = (e: Event) => { + const event = new CustomEvent("navigate", { + detail: { + // @ts-ignore + id: e.currentTarget.dataset.id, + }, + bubbles: true, + cancelable: true, + }); + this.dispatchEvent(event); + }; + + private renderIcon(icon: string) { + let out: TemplateResult | string = ""; + if (icon.length) { + out = html` ${unsafeHTML(decodeURI(icon))} `; + } else { + out = ""; + } + return out; + } + + private renderLink(link: ILink, renderArrowIcon: boolean = false) { + if (!link?.label && !link?.icon) { + return ""; + } + return html` + + ${renderArrowIcon + ? html` + + + + + ` + : ""} + `; + } + + override render() { + let view: TemplateResult; + if (this.model.links.length <= 3) { + view = html` + ${this.model.links.map((link, i) => { + let renderArrowIcon = true; + if (i === this.model.links.length - 1) { + renderArrowIcon = false; + } + return this.renderLink(link, renderArrowIcon); + })} + `; + } else { + view = html` + ${this.renderLink(this.model.links[0], true)} + + + + ${this.model.links.map((link, i) => { + if (i !== 0 && i !== this.model.links.length - 1) { + return this.renderLink(link); + } + })} + + + + + + + ${this.renderLink(this.model.links[this.model.links.length - 1])} + `; + } + render(view, this); + } +} +env.bind("breadcrumb-trail", BreadcrumbTrail); diff --git a/client/src/framework/components/breadcrumb-trail/index.html b/client/src/framework/components/breadcrumb-trail/index.html new file mode 100644 index 0000000..9af92f8 --- /dev/null +++ b/client/src/framework/components/breadcrumb-trail/index.html @@ -0,0 +1,14 @@ +
+ +
+ +
+ +
+ + + diff --git a/client/src/framework/components/breadcrumb-trail/readme.md b/client/src/framework/components/breadcrumb-trail/readme.md new file mode 100644 index 0000000..22f9d46 --- /dev/null +++ b/client/src/framework/components/breadcrumb-trail/readme.md @@ -0,0 +1,36 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| links | BreadcrumbLink[] | ✅ | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Breadcrumb Link + +```typescript +type BreadcrumbLink = { + label?: string; + icon?: string; + ariaLabel?: string; + id: string; +} +``` + +### HTML Content + +You can render HTML content for a link icon by using the `encodeURI()` function. [Learn more about URI encoding on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI). + +```javascript +html` + ')}"}]' + > +` +``` diff --git a/client/src/framework/components/breadcrumb-trail/static.html b/client/src/framework/components/breadcrumb-trail/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/buttons/button/button.scss b/client/src/framework/components/buttons/button/button.scss new file mode 100644 index 0000000..ce4bffe --- /dev/null +++ b/client/src/framework/components/buttons/button/button.scss @@ -0,0 +1,951 @@ +.bttn, +button-component { + display: inline-flex; + justify-content: center; + align-items: center; + flex-flow: row nowrap; + position: relative; + border: 2px solid transparent; + background-color: transparent; + transition: all 80ms var(--ease-in-out); + padding: 0 1rem; + min-height: 36px; + box-shadow: none; + font-size: var(--font-sm); + font-weight: var(--font-medium); + user-select: none; + text-overflow: ellipsis; + cursor: pointer; + border-radius: 0.25rem; + line-height: 1; + outline-offset: 0; + text-align: center; + + &::before { + content: ""; + display: inline-block; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + transition: all 80ms var(--ease-in-out); + border-radius: 0.25rem; + } + + &:disabled, + &[disabled] { + border-color: var(--grey-200) !important; + background-color: var(--grey-50) !important; + color: var(--grey-400) !important; + cursor: not-allowed !important; + box-shadow: none !important; + opacity: 0.6; + + &::before { + background: transparent !important; + } + + &[kind="text"] { + border-color: transparent !important; + background-color: transparent !important; + } + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-700) !important; + background-color: transparent !important; + color: var(--grey-500) !important; + + &::before { + background: transparent !important; + } + + &[kind="text"] { + border-color: transparent !important; + background-color: transparent !important; + } + } + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + transition: outline-offset 80ms var(--ease-in-out); + + &[color="grey"] { + outline-color: var(--grey-900); + } + + &[color="primary"] { + outline-color: var(--primary-500); + } + + &[color="danger"] { + outline-color: var(--danger-500); + } + + &[color="warning"] { + outline-color: var(--warning-500); + } + + &[color="success"] { + outline-color: var(--success-500); + } + + @media (prefers-color-scheme: dark) { + &[color="grey"] { + outline-color: var(--grey-400); + } + + &[color="primary"] { + outline-color: var(--primary-400); + } + + &[color="danger"] { + outline-color: var(--danger-400); + } + + &[color="warning"] { + outline-color: var(--warning-400); + } + + &[color="success"] { + outline-color: var(--success-400); + } + } + } + + &:active, + &.is-active { + outline-offset: 0px !important; + } + + &[size="slim"] { + padding: 0 0.5rem; + min-height: 28px; + } + + &[size="large"] { + padding: 0 1.5rem; + min-height: 42px; + min-width: 42px; + + & > svg { + width: 20px; + height: 20px; + } + } + + &[shape="sharp"] { + border-radius: 0; + + &::before { + border-radius: 0; + } + } + + &[shape="round"] { + border-radius: 50%; + + &::before { + border-radius: 50%; + } + } + + &[shape="pill"] { + border-radius: 18px; + + &::before { + border-radius: 18px; + } + + &[size="slim"] { + border-radius: 14px; + + &::before { + border-radius: 14px; + } + } + + &[size="large"] { + border-radius: 21px; + + &::before { + border-radius: 21px; + } + } + } + + &[icon="center"] { + width: 36px; + height: 36px; + padding: 0 !important; + } + + &[icon="right"] { + padding: 0 0.5rem 0 1rem; + + & > svg { + margin-left: 0.5rem; + order: 2; + } + + & > span { + order: 1; + } + } + + &[icon="left"] { + padding: 0 1rem 0 0.5rem; + + & > svg { + margin-right: 0.5rem; + } + } + + & > svg { + width: 20px; + height: 20px; + } + + &[kind="solid"] { + &[dull] { + border-color: var(--grey-200); + background-color: var(--white); + color: var(--grey-700); + box-shadow: var(--button-shadow); + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-400); + background-color: var(--grey-400); + color: var(--grey-950); + box-shadow: none; + } + + &[color="primary"] { + &:hover, + &:focus-visible { + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--primary-500-hsl) / 0.05); + border-color: var(--primary-200); + + @media (prefers-color-scheme: dark) { + background-color: var(--primary-400); + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--primary-400-hsl) / 0.1); + } + } + } + + &[color="danger"] { + &:hover, + &:focus-visible { + border-color: var(--danger-200); + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--danger-500-hsl) / 0.05); + + @media (prefers-color-scheme: dark) { + background-color: var(--danger-400); + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--danger-400-hsl) / 0.1); + } + } + } + + &[color="warning"] { + &:hover, + &:focus-visible { + border-color: var(--warning-200); + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--warning-500-hsl) / 0.05); + + @media (prefers-color-scheme: dark) { + background-color: var(--warning-400); + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--warning-300-hsl) / 0.1); + } + } + } + + &[color="success"] { + &:hover, + &:focus-visible { + border-color: var(--success-200); + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--success-500-hsl) / 0.05); + + @media (prefers-color-scheme: dark) { + background-color: var(--success-400); + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--success-400-hsl) / 0.1); + } + } + } + } + + &[color="primary"]:not([dull]) { + border-color: var(--primary-200); + background-color: var(--white); + color: var(--primary-700); + box-shadow: var(--button-shadow); + + &:hover { + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--primary-500-hsl) / 0.05); + } + + @media (prefers-color-scheme: dark) { + border-color: var(--primary-400); + background-color: var(--primary-400); + color: var(--grey-950); + box-shadow: none; + + &:hover { + border-color: var(--primary-200); + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--primary-400-hsl) / 0.1); + } + &:focus-visible { + border-color: var(--primary-200); + } + } + } + + &[color="danger"]:not([dull]) { + border-color: var(--danger-200); + background-color: var(--white); + color: var(--danger-700); + box-shadow: var(--button-shadow); + + &:hover { + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--danger-500-hsl) / 0.05); + } + + @media (prefers-color-scheme: dark) { + border-color: var(--danger-400); + background-color: var(--danger-400); + color: var(--grey-950); + box-shadow: none; + + &:hover { + border-color: var(--danger-200); + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--danger-400-hsl) / 0.1); + } + &:focus-visible { + border-color: var(--danger-200); + } + } + } + + &[color="grey"], + &[color="white"] { + border-color: var(--grey-200); + background-color: var(--white); + color: var(--grey-700); + box-shadow: var(--button-shadow); + + &:hover { + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--grey-500-hsl) / 0.05); + } + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-400); + background-color: var(--grey-400); + color: var(--grey-950); + box-shadow: none; + + &:hover { + border-color: var(--grey-200); + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--grey-400-hsl) / 0.1); + } + &:focus-visible { + border-color: var(--grey-200); + } + } + } + + &[color="warning"]:not([dull]) { + border-color: var(--warning-200); + background-color: var(--white); + color: var(--warning-700); + box-shadow: var(--button-shadow); + + &:hover { + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--warning-500-hsl) / 0.05); + } + + @media (prefers-color-scheme: dark) { + border-color: var(--warning-400); + background-color: var(--warning-400); + color: var(--grey-950); + box-shadow: none; + + &:hover { + border-color: var(--warning-200); + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--warning-300-hsl) / 0.1); + } + &:focus-visible { + border-color: var(--warning-200); + } + } + } + + &[color="success"]:not([dull]) { + border-color: var(--success-200); + background-color: var(--white); + color: var(--success-700); + box-shadow: var(--button-shadow); + + &:hover { + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--success-500-hsl) / 0.05); + } + + @media (prefers-color-scheme: dark) { + border-color: var(--success-400); + background-color: var(--success-400); + color: var(--grey-950); + box-shadow: none; + + &:hover { + border-color: var(--success-200); + box-shadow: var(--button-shadow), 0 0 0 8px hsl(var(--success-400-hsl) / 0.1); + } + &:focus-visible { + border-color: var(--success-200); + } + } + } + + &:active, + &.is-active { + box-shadow: none !important; + transform: translateY(1px); + } + } + + &[kind="text"] { + padding: 0 0.5rem; + + &::before { + opacity: 0; + } + + &[dull] { + color: var(--grey-700); + + &::before { + background-color: var(--grey-500); + } + + &[icon="center"] { + color: var(--grey-400); + } + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + + &::before { + background-color: var(--grey-400); + } + + &[icon="center"] { + color: var(--grey-500); + } + } + + &[color="primary"] { + &:hover, + &:focus-visible { + color: var(--primary-700); + + &::before { + background-color: var(--primary-500); + } + + @media (prefers-color-scheme: dark) { + color: var(--primary-400); + + &::before { + background-color: var(--primary-400); + } + } + } + } + + &[color="danger"] { + &:hover, + &:focus-visible { + color: var(--danger-700); + + &::before { + background-color: var(--danger-500); + } + + @media (prefers-color-scheme: dark) { + color: var(--danger-400); + + &::before { + background-color: var(--danger-400); + } + } + } + } + + &[color="warning"] { + &:hover, + &:focus-visible { + color: var(--warning-700); + + &::before { + background-color: var(--warning-500); + } + + @media (prefers-color-scheme: dark) { + color: var(--warning-400); + + &::before { + background-color: var(--warning-400); + } + } + } + } + + &[color="success"] { + &:hover, + &:focus-visible { + color: var(--success-700); + + &::before { + background-color: var(--success-500); + } + + @media (prefers-color-scheme: dark) { + color: var(--success-400); + + &::before { + background-color: var(--success-400); + } + } + } + } + } + + &[color="white"] { + color: var(--white); + + &::before { + background-color: var(--white); + } + + &[icon="center"] { + color: var(--grey-50); + } + + @media (prefers-color-scheme: dark) { + color: var(--white); + + &::before { + background-color: var(--grey-400); + } + + &[icon="center"] { + color: var(--white); + } + } + } + + &[color="grey"] { + color: var(--grey-700); + + &::before { + background-color: var(--grey-500); + } + + &[icon="center"] { + color: var(--grey-400); + + &:hover, + &:focus-visible, + &:active, + &.is-active { + color: var(--grey-700); + } + } + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + + &::before { + background-color: var(--grey-400); + } + + &[icon="center"] { + color: var(--grey-500); + + &:hover, + &:focus-visible, + &:active, + &.is-active { + color: var(--grey-300); + } + } + } + } + + &[color="primary"]:not([dull]) { + color: var(--primary-700); + + &::before { + background-color: var(--primary-500); + } + + &[icon="center"] { + color: var(--primary-400); + + &:hover, + &:focus-visible, + &:active, + &.is-active { + color: var(--primary-700); + } + } + + @media (prefers-color-scheme: dark) { + color: var(--primary-400); + + &::before { + background-color: var(--primary-400); + } + + &[icon="center"] { + color: var(--primary-500); + + &:hover, + &:focus-visible, + &:active, + &.is-active { + color: var(--primary-400); + } + } + } + } + + &[color="danger"]:not([dull]) { + color: var(--danger-700); + + &::before { + background-color: var(--danger-500); + } + + &[icon="center"] { + color: var(--danger-400); + + &:hover, + &:focus-visible, + &:active, + &.is-active { + color: var(--danger-700); + } + } + + @media (prefers-color-scheme: dark) { + color: var(--danger-400); + + &::before { + background-color: var(--danger-400); + } + + &[icon="center"] { + color: var(--danger-500); + + &:hover, + &:focus-visible, + &:active, + &.is-active { + color: var(--danger-400); + } + } + } + } + + &[color="success"]:not([dull]) { + color: var(--success-700); + + &::before { + background-color: var(--success-500); + } + + &[icon="center"] { + color: var(--success-400); + + &:hover, + &:focus-visible, + &:active, + &.is-active { + color: var(--success-700); + } + } + + @media (prefers-color-scheme: dark) { + color: var(--success-400); + + &::before { + background-color: var(--success-400); + } + + &[icon="center"] { + color: var(--success-500); + + &:hover, + &:focus-visible, + &:active, + &.is-active { + color: var(--success-400); + } + } + } + } + + &[color="warning"]:not([dull]) { + color: var(--warning-700); + + &::before { + background-color: var(--warning-500); + } + + &[icon="center"] { + color: var(--warning-400); + + &:hover, + &:focus-visible, + &:active, + &.is-active { + color: var(--warning-700); + } + } + + @media (prefers-color-scheme: dark) { + color: var(--warning-400); + + &::before { + background-color: var(--warning-400); + } + + &[icon="center"] { + color: var(--warning-500); + + &:hover, + &:focus-visible, + &:active, + &.is-active { + color: var(--warning-400); + } + } + } + } + + &:hover, + &:focus-visible { + &::before { + opacity: 0.05; + } + } + + &:active, + &.is-active { + &::before { + opacity: 0.1; + } + } + } + + &[kind="dashed"] { + border-style: dashed; + } + + &[kind="outline"], + &[kind="dashed"] { + &::before { + opacity: 0; + } + + &[dull] { + color: var(--grey-700); + border-color: var(--grey-200); + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-600); + color: var(--grey-300); + } + + &[color="primary"] { + &::before { + background-color: var(--primary-500); + } + + &:hover, + &:focus-visible { + color: var(--primary-700); + border-color: var(--primary-200); + + @media (prefers-color-scheme: dark) { + color: var(--primary-400); + } + } + } + + &[color="danger"] { + &::before { + background-color: var(--danger-500); + } + + &:hover, + &:focus-visible { + color: var(--danger-700); + border-color: var(--danger-200); + + @media (prefers-color-scheme: dark) { + color: var(--danger-400); + } + } + } + + &[color="warning"] { + &::before { + background-color: var(--warning-500); + } + + &:hover, + &:focus-visible { + color: var(--warning-700); + border-color: var(--warning-200); + + @media (prefers-color-scheme: dark) { + color: var(--warning-400); + } + } + } + + &[color="success"] { + &::before { + background-color: var(--success-500); + } + + &:hover, + &:focus-visible { + color: var(--success-700); + border-color: var(--success-200); + + @media (prefers-color-scheme: dark) { + color: var(--success-400); + } + } + } + } + + &[color="white"] { + color: var(--white); + border-color: var(--grey-200); + + &::before { + background-color: var(--white); + } + } + + &[color="grey"] { + color: var(--grey-700); + border-color: var(--grey-200); + + &::before { + background-color: var(--grey-500); + } + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + + &::before { + background-color: var(--grey-400); + } + } + } + + &[color="primary"]:not([dull]) { + color: var(--primary-700); + border-color: var(--primary-200); + + &::before { + background-color: var(--primary-500); + } + + @media (prefers-color-scheme: dark) { + color: var(--primary-400); + + &::before { + background-color: var(--primary-400); + } + } + } + + &[color="danger"]:not([dull]) { + color: var(--danger-700); + border-color: var(--danger-200); + + &::before { + background-color: var(--danger-500); + } + + @media (prefers-color-scheme: dark) { + color: var(--danger-400); + + &::before { + background-color: var(--danger-400); + } + } + } + + &[color="success"]:not([dull]) { + color: var(--success-700); + border-color: var(--success-200); + + &::before { + background-color: var(--success-500); + } + + @media (prefers-color-scheme: dark) { + color: var(--success-400); + + &::before { + background-color: var(--success-400); + } + } + } + + &[color="warning"]:not([dull]) { + color: var(--warning-700); + border-color: var(--warning-200); + + &::before { + background-color: var(--warning-500); + } + + @media (prefers-color-scheme: dark) { + color: var(--warning-400); + + &::before { + background-color: var(--warning-400); + } + } + } + + &:hover, + &:focus-visible { + &::before { + opacity: 0.05; + } + } + + &:active, + &.is-active { + &::before { + opacity: 0.1; + } + } + } + + span { + display: inline-block; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/client/src/framework/components/buttons/button/button.ts b/client/src/framework/components/buttons/button/button.ts new file mode 100644 index 0000000..18614c6 --- /dev/null +++ b/client/src/framework/components/buttons/button/button.ts @@ -0,0 +1,133 @@ +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import Component from "~brixi/component"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; + +env.css(["button"]); + +export type ButtonKind = "solid" | "outline" | "text"; +export type ButtonColor = "primary" | "danger" | "grey" | "success" | "warning" | "white"; +export type ButtonShape = "pill" | "round" | "sharp" | "default"; +export type ButtonSize = "default" | "slim" | "large"; +export type ButtonType = "submit" | "button" | "reset"; + +export interface IButton { + label: string; + icon: string; + iconPosition: "left" | "right" | "center"; + kind: ButtonKind; + color: ButtonColor; + shape: ButtonShape; + size: ButtonSize; + disabled: boolean; + type: ButtonType; +} +export default class Button extends Component { + constructor() { + super(); + this.model = { + label: null, + kind: "solid", + color: "grey", + shape: "default", + size: "default", + icon: "", + iconPosition: "left", + disabled: false, + type: "button", + }; + } + + static get observedAttributes() { + return ["data-label", "data-icon", "data-icon-position", "data-kind", "data-color", "data-shape", "data-size", "data-disabled", "data-type"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + this.addEventListener("keydown", this.handleKeydown); + this.addEventListener("keyup", this.handleKeyup); + this.addEventListener("click", this.handleClick); + } + + private renderIcon(): string | TemplateResult { + let icon: string | TemplateResult = ""; + if (this.model.icon.length) { + icon = html`${unsafeHTML(this.model.icon)}`; + } else { + icon = ""; + } + return icon; + } + + private renderLabel(): string | TemplateResult { + let label: string | TemplateResult = ""; + if (this.model.label != null) { + label = html`${this.model.label}`; + } else { + label = ""; + } + return label; + } + + private dispatchClick() { + this.dispatchEvent(new CustomEvent("action", { bubbles: true, cancelable: true })); + } + + private handleClick: EventListener = () => { + if (this.model.disabled) { + return; + } + this.dispatchClick(); + }; + + private handleKeydown: EventListener = (e: KeyboardEvent) => { + if (e instanceof KeyboardEvent) { + const key = e.key.toLowerCase(); + if (key === " ") { + e.stopImmediatePropagation(); + if (this.model.disabled) { + return; + } + this.classList.add("is-active"); + } + } + }; + + private handleKeyup: EventListener = (e: KeyboardEvent) => { + if (e instanceof KeyboardEvent) { + const key = e.key.toLowerCase(); + if (key === " ") { + e.stopImmediatePropagation(); + if (this.model.disabled) { + return; + } + this.classList.remove("is-active"); + this.dispatchClick(); + } + } + }; + + override render() { + const view = html` ${this.renderIcon()} ${this.renderLabel()} `; + this.setAttribute("role", "button"); + this.tabIndex = 0; + this.setAttribute("color", this.model.color); + this.setAttribute("size", this.model.size); + this.setAttribute("kind", this.model.kind); + this.setAttribute("shape", this.model.shape); + this.setAttribute("type", this.model.type); + if (this.model.icon.length) { + this.setAttribute("icon", this.model.iconPosition); + } + this.setAttribute("sfx", "button"); + if (this.model.disabled) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + render(view, this); + } +} +env.bind("button-component", Button); diff --git a/client/src/framework/components/buttons/button/index.html b/client/src/framework/components/buttons/button/index.html new file mode 100644 index 0000000..e316ea0 --- /dev/null +++ b/client/src/framework/components/buttons/button/index.html @@ -0,0 +1,94 @@ +
+ + + + + + + + + + +
+
+ + + + + + + +
+
+ + + + + +
+
+ + + + + + + +
+
+ + + + + +
+ +
+ + + + +
diff --git a/client/src/framework/components/buttons/button/readme.md b/client/src/framework/components/buttons/button/readme.md new file mode 100644 index 0000000..7c89637 --- /dev/null +++ b/client/src/framework/components/buttons/button/readme.md @@ -0,0 +1,40 @@ +```html + + + + + + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| label | string | | +| icon | string | | +| iconPosition | ButtonPositions | | +| kind | ButtonKind | ✅ | +| color | ButtonColor | ✅ | +| shape | ButtonShape | | +| size | ButtonSize | | +| disabled | boolean | | +| type | ButtonType | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type ButtonPositions = "left" | "right" | "center"; +type ButtonKind = "solid" | "outline" | "text"; +type ButtonColor = "primary" | "danger" | "grey" | "success" | "warning" | "white"; +type ButtonShape = "pill" | "round" | "sharp" | "default"; +type ButtonSize = "default" | "slim" | "large"; +type ButtonType = "submit" | "button" | "reset"; +``` diff --git a/client/src/framework/components/buttons/button/static.html b/client/src/framework/components/buttons/button/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/buttons/download-button/download-button.scss b/client/src/framework/components/buttons/download-button/download-button.scss new file mode 100644 index 0000000..067b0d8 --- /dev/null +++ b/client/src/framework/components/buttons/download-button/download-button.scss @@ -0,0 +1,4 @@ +download-button { + display: inline-flex; + position: relative; +} diff --git a/client/src/framework/components/buttons/download-button/download-button.ts b/client/src/framework/components/buttons/download-button/download-button.ts new file mode 100644 index 0000000..fb42a37 --- /dev/null +++ b/client/src/framework/components/buttons/download-button/download-button.ts @@ -0,0 +1,186 @@ +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import ProgressIndicator from "~brixi/components/progress/progress-indicator/progress-indicator"; +import Component from "~brixi/component"; +import type { ButtonColor, ButtonKind, ButtonShape, ButtonSize } from "../button/button"; + +env.css(["button", "download-button"]); + +export interface IDownloadButton { + label: string; + icon: string; + kind: ButtonKind; + color: ButtonColor; + shape: ButtonShape; + size: ButtonSize; + url: RequestInfo; + options: RequestInit; + downloadingLabel: string; + workerURL: string; +} +export default class DownloadButton extends Component { + private indicator: ProgressIndicator; + private downloading: boolean; + + constructor() { + super(); + this.downloading = false; + this.model = { + label: "", + downloadingLabel: "downloading", + kind: "solid", + color: "primary", + shape: "default", + size: "default", + icon: "", + url: location.origin, + options: { + method: "GET", + }, + workerURL: "/js/file-download-worker.js", + }; + } + + static get observedAttributes() { + return ["data-label", "data-icon", "data-kind", "data-color", "data-shape", "data-size", "data-url", "data-options", "data-worker-url", "data-downloading-label"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + this.addEventListener("click", this.handleClick); + this.addEventListener("keydown", this.handleKeydown); + this.addEventListener("keyup", this.handleKeyup); + } + + private async fetchData() { + if (this.downloading) { + return; + } + this.downloading = true; + const icon: HTMLElement = this.querySelector("svg, img"); + if (icon) { + icon.style.display = "none"; + } + const label: HTMLElement = this.querySelector("span"); + if (label) { + label.innerText = this.model.downloadingLabel; + } + const worker = new Worker(this.model.workerURL); + worker.onmessage = (e: MessageEvent) => { + const { type, data } = e.data; + switch (type) { + case "tick": + // @ts-ignore + this.indicator.tick(data); + break; + case "start": + this.indicator = new ProgressIndicator(); + this.indicator.className = "mr-0.5"; + this.indicator.style.marginLeft = "-0.25rem"; + // @ts-ignore + this.indicator.set({ + total: data, + color: "grey", + }); + this.insertBefore(this.indicator, this.childNodes[0]); + break; + case "done": + this.dispatchEvent( + new CustomEvent("download", { + detail: { + blob: new Blob([data]), + }, + bubbles: true, + cancelable: true, + }) + ); + this.indicator.remove(); + label.innerText = this.model.label; + if (icon) { + icon.style.display = "inline-block"; + } + worker.terminate(); + this.downloading = false; + break; + case "error": + this.dispatchEvent( + new CustomEvent("error", { + detail: { + error: data, + }, + bubbles: true, + cancelable: true, + }) + ); + worker.terminate(); + this.indicator.remove(); + label.innerText = this.model.label; + if (icon) { + icon.style.display = "inline-block"; + } + this.downloading = false; + break; + default: + console.warn(`Unhandled file download worker message type: ${type}`); + break; + } + }; + worker.postMessage({ + url: this.model.url, + options: this.model.options, + }); + } + + private handleClick: EventListener = (e: Event) => { + this.fetchData(); + }; + + private handleKeydown: EventListener = (e: KeyboardEvent) => { + if (e instanceof KeyboardEvent) { + const key = e.key.toLowerCase(); + if (key === " ") { + this.classList.add("is-active"); + } + } + }; + + private handleKeyup: EventListener = (e: KeyboardEvent) => { + if (e instanceof KeyboardEvent) { + const key = e.key.toLowerCase(); + if (key === " ") { + this.classList.remove("is-active"); + this.fetchData(); + } + } + }; + + private renderIcon() { + let icon: string | TemplateResult; + if (this.model.icon.length) { + icon = html`${unsafeHTML(this.model.icon)}`; + } else { + icon = ""; + } + return icon; + } + + override render() { + const view = html` ${this.renderIcon()} ${this.model.label} `; + this.classList.add("bttn"); + this.setAttribute("role", "button"); + this.tabIndex = 0; + this.setAttribute("color", this.model.color); + this.setAttribute("size", this.model.size); + this.setAttribute("kind", this.model.kind); + this.setAttribute("shape", this.model.shape); + if (this.model.icon?.length) { + this.setAttribute("icon", "left"); + } + this.setAttribute("sfx", "button"); + render(view, this); + } +} +env.bind("download-button-component", DownloadButton); diff --git a/client/src/framework/components/buttons/download-button/index.html b/client/src/framework/components/buttons/download-button/index.html new file mode 100644 index 0000000..8caf659 --- /dev/null +++ b/client/src/framework/components/buttons/download-button/index.html @@ -0,0 +1,19 @@ + + + + diff --git a/client/src/framework/components/buttons/download-button/readme.md b/client/src/framework/components/buttons/download-button/readme.md new file mode 100644 index 0000000..25d686d --- /dev/null +++ b/client/src/framework/components/buttons/download-button/readme.md @@ -0,0 +1,65 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| label | string | | +| icon | string | | +| kind | ButtonKind | ✅ | +| color | ButtonColor | ✅ | +| shape | ButtonShape | | +| size | ButtonSize | | +| disabled | boolean | | +| url | string | ✅ | +| options | RequestInit | | +| downloadingLabel | string | | +| workerURL | string | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +Not sure what `RequestInit` is? Learn about [Requests on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request). + +### Types + +```typescript +type ButtonKind = "solid" | "outline" | "text"; +type ButtonColor = "primary" | "danger" | "grey" | "success" | "warning" | "white"; +type ButtonShape = "pill" | "round" | "sharp" | "default"; +type ButtonSize = "default" | "slim" | "large"; +``` + +### Event Listeners + +#### Download + +The `download` event will fire after the file has been downloaded and the [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) is ready to be used. + +```typescript +document.body.querySelector('download-button-component').addEventListener('download', (e) => { + const url = URL.createObjectURL(e.detail.blob); + const a = document.createElement("a"); + a.download = "example.zip"; + a.href = url; + a.target = "_blank"; + a.click(); + URL.revokeObjectURL(url); +}); +``` + +#### Error + +The `error` event will fire when the file fails to download. + +```typescript +document.body.querySelector('download-button-component').addEventListener('error', (e) => { + console.error(e.detail.error); +}); +``` diff --git a/client/src/framework/components/buttons/download-button/static.html b/client/src/framework/components/buttons/download-button/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/buttons/group-button/group-button.scss b/client/src/framework/components/buttons/group-button/group-button.scss new file mode 100644 index 0000000..9c5ce1b --- /dev/null +++ b/client/src/framework/components/buttons/group-button/group-button.scss @@ -0,0 +1,101 @@ +group-button-component { + display: inline-flex; + align-items: center; + flex-flow: row nowrap; + position: relative; + + button { + transform: none !important; + box-shadow: none !important; + + @media (prefers-color-scheme: dark) { + border: none !important; + background-color: hsl(var(--grey-950-hsl) / 0.6) !important; + + &:hover, + &:focus-visible { + &::before { + opacity: 0.1 !important; + } + } + &:active { + &::before { + opacity: 0.15 !important; + } + } + } + + &:first-of-type:not(:only-child) { + border-radius: 18px 0 0 18px !important; + border-width: 1px 0 1px 1px !important; + } + &:last-of-type:not(:only-child) { + border-radius: 0 18px 18px 0 !important; + border-width: 1px 1px 1px 0 !important; + } + &:not(:first-of-type):not(:last-of-type):not(:only-child) { + border-radius: 0 !important; + border-width: 1px 0 1px 0 !important; + } + + &:not(:last-of-type):not(:only-child) { + &::after { + content: ""; + position: absolute; + top: 50%; + right: -1px; + bottom: 0; + width: 2px; + height: 50%; + border-radius: 1px; + background-color: var(--grey-200); + transform: translateY(-50%); + + @media (prefers-color-scheme: dark) { + background-color: var(--grey-800); + } + } + } + + &:first-of-type { + &::before { + border-radius: 18px 0 0 18px !important; + } + } + + &:last-of-type { + &::before { + border-radius: 0 18px 18px 0 !important; + } + } + + &:only-child { + border-radius: 18px !important; + border-width: 1px !important; + + &::before { + border-radius: 18px !important; + } + } + + &::before { + border-radius: 0 !important; + } + + &.is-active { + color: var(--primary-700) !important; + + &::before { + background-color: var(--primary-500) !important; + opacity: 0.05 !important; + } + + @media (prefers-color-scheme: dark) { + color: var(--primary-300) !important; + &::before { + opacity: 0 !important; + } + } + } + } +} diff --git a/client/src/framework/components/buttons/group-button/group-button.ts b/client/src/framework/components/buttons/group-button/group-button.ts new file mode 100644 index 0000000..fcdd3ca --- /dev/null +++ b/client/src/framework/components/buttons/group-button/group-button.ts @@ -0,0 +1,103 @@ +import { html, render, TemplateResult } from "lit-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import type { ButtonType } from "~brixi/components/buttons/button/button"; +import Component from "~brixi/component"; + +env.css(["group-button", "button"]); + +export interface IGroupButton { + buttons: Array<{ + label: string; + type?: ButtonType; + icon?: string; + id: string; + }>; + active?: string; +} +export default class GroupButton extends Component { + constructor() { + super(); + this.model = { + buttons: [], + active: null, + }; + } + + static get observedAttributes() { + return ["data-buttons", "data-active"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + private handleClick = (e: Event) => { + const target = e.currentTarget as HTMLElement; + this.set({ + active: target.dataset.id, + }); + const event = new CustomEvent("change", { + detail: { + id: target.dataset.id, + }, + bubbles: true, + cancelable: true, + }); + this.dispatchEvent(event); + }; + + private renderIcon(icon: string) { + let out: TemplateResult | string; + if (icon?.length) { + out = html` ${unsafeHTML(decodeURI(icon))} `; + } else { + out = ""; + } + return out; + } + + private renderLabel(label: string) { + let out: TemplateResult | string; + if (label) { + out = html` ${label} `; + } else { + out = ""; + } + return out; + } + + private renderButtons() { + let out: TemplateResult | string; + if (!this.model.buttons.length) { + out = ""; + } else { + out = html` + ${this.model.buttons.map((button) => { + return html` + + `; + })} + `; + } + return out; + } + + override render() { + const view = html` ${this.renderButtons()} `; + render(view, this); + } +} +env.bind("group-button-component", GroupButton); diff --git a/client/src/framework/components/buttons/group-button/index.html b/client/src/framework/components/buttons/group-button/index.html new file mode 100644 index 0000000..2e7f149 --- /dev/null +++ b/client/src/framework/components/buttons/group-button/index.html @@ -0,0 +1,9 @@ + + + diff --git a/client/src/framework/components/buttons/group-button/readme.md b/client/src/framework/components/buttons/group-button/readme.md new file mode 100644 index 0000000..74b3e44 --- /dev/null +++ b/client/src/framework/components/buttons/group-button/readme.md @@ -0,0 +1,49 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| active | string | | +| buttons | Button[] | ✅ | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type Button = { + label: string; + type?: ButtonType; + icon?: string; + id: string; +} +type ButtonType = "submit" | "button" | "reset"; +``` + +### HTML Content + +You can render HTML content for a button icon by using the `encodeURI()` function. [Learn more about URI encoding on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI). + +```javascript +html` + ')}","label":"Example 1","id":"example-1"}]' + > +` +``` + +### Event Listeners + +The `change` event will fire when the user clicks on one of the buttons. + +```typescript +document.body.querySelector('group-button-component').addEventListener('change', (e) => { + console.error(e.detail.id); +}); +``` diff --git a/client/src/framework/components/buttons/group-button/static.html b/client/src/framework/components/buttons/group-button/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/buttons/overflow-button/index.html b/client/src/framework/components/buttons/overflow-button/index.html new file mode 100644 index 0000000..af7460b --- /dev/null +++ b/client/src/framework/components/buttons/overflow-button/index.html @@ -0,0 +1,11 @@ + + + + + diff --git a/client/src/framework/components/buttons/overflow-button/overflow-button.scss b/client/src/framework/components/buttons/overflow-button/overflow-button.scss new file mode 100644 index 0000000..5a50371 --- /dev/null +++ b/client/src/framework/components/buttons/overflow-button/overflow-button.scss @@ -0,0 +1,3 @@ +overflow-button { + position: relative; +} diff --git a/client/src/framework/components/buttons/overflow-button/overflow-button.ts b/client/src/framework/components/buttons/overflow-button/overflow-button.ts new file mode 100644 index 0000000..c7eea0f --- /dev/null +++ b/client/src/framework/components/buttons/overflow-button/overflow-button.ts @@ -0,0 +1,82 @@ +import { html, render } from "lit-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import OverflowMenu, { OverflowItem } from "~brixi/components/overflow-menu/overflow-menu"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import { UUID } from "@codewithkyle/uuid"; +import Component from "~brixi/component"; +import type { ButtonColor, ButtonKind, ButtonShape, ButtonSize } from "../button/button"; + +env.css(["button"]); + +export interface IOverflowButton { + icon: string; + iconPosition: "left" | "right" | "center"; + kind: ButtonKind; + color: ButtonColor; + shape: ButtonShape; + size: ButtonSize; + disabled: boolean; + items: Array; +} +export default class OverflowButton extends Component { + private uid: string; + + constructor() { + super(); + this.uid = UUID(); + this.model = { + kind: "text", + color: "grey", + shape: "round", + size: "default", + icon: ``, + iconPosition: "center", + disabled: false, + items: [], + }; + } + + static get observedAttributes() { + return ["data-kind", "data-color", "data-shape", "data-size", "data-icon", "data-icon-position", "data-disabled", "data-items"]; + } + + override connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + this.addEventListener("click", this.handleClick); + } + + private handleClick: EventListener = () => { + new OverflowMenu({ + uid: this.uid, + items: this.model.items, + target: this, + callback: (id: string) => { + const event = new CustomEvent("action", { + detail: { + id: id, + }, + bubbles: true, + cancelable: true, + }); + this.dispatchEvent(event); + }, + }); + }; + + override render() { + this.classList.add("bttn"); + this.setAttribute("kind", this.model.kind); + this.setAttribute("color", this.model.color); + this.setAttribute("shape", this.model.shape); + this.setAttribute("icon", this.model.iconPosition); + this.setAttribute("size", this.model.size); + if (this.model.disabled) { + this.setAttribute("disabled", `${this.model.disabled}`); + } + const view = html` ${unsafeHTML(this.model.icon)} `; + render(view, this); + } +} +env.bind("overflow-button", OverflowButton); diff --git a/client/src/framework/components/buttons/overflow-button/readme.md b/client/src/framework/components/buttons/overflow-button/readme.md new file mode 100644 index 0000000..2b97a84 --- /dev/null +++ b/client/src/framework/components/buttons/overflow-button/readme.md @@ -0,0 +1,70 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| icon | string | | +| iconPosition | ButtonPositions | | +| kind | ButtonKind | ✅ | +| color | ButtonColor | ✅ | +| shape | ButtonShape | | +| size | ButtonSize | | +| disabled | boolean | | +| items | OverflowItem[] | ✅ | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type ButtonPositions = "left" | "right" | "center"; +type ButtonKind = "solid" | "outline" | "text"; +type ButtonColor = "primary" | "danger" | "grey" | "success" | "warning" | "white"; +type ButtonShape = "pill" | "round" | "sharp" | "default"; +type ButtonSize = "default" | "slim" | "large"; +type ButtonType = "submit" | "button" | "reset"; +type OverflowItem = { + label: string; + id: string; + icon?: string; + danger?: boolean; +} +``` + +### HTML Content + +You can render HTML content within a overflow item by using the `encodeURI()` function. [Learn more about URI encoding on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI). + +```javascript +html` + +` +``` + +### Horizontal Rules + +You can render a horizontal rule within the overlfow item list by replacing the `OverflowItem` with a `null` value. + +### Event Listeners + +The `action` event will fire when the user clicks on one of the overflow item buttons. + +```typescript +document.body.querySelector('overflow-button-component').addEventListener('action', (e) => { + console.error(e.detail.id); +}); +``` diff --git a/client/src/framework/components/buttons/overflow-button/static.html b/client/src/framework/components/buttons/overflow-button/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/buttons/split-button/index.html b/client/src/framework/components/buttons/split-button/index.html new file mode 100644 index 0000000..9cbcae5 --- /dev/null +++ b/client/src/framework/components/buttons/split-button/index.html @@ -0,0 +1,11 @@ + + + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| label | string | ✅ | +| id | string | ✅ | +| type | ButtonType | | +| icon | string | | +| buttons | OverflowItem[] | ✅ | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type OverflowItem = { + label: string; + id: string; + icon?: string; + danger?: boolean; +}; +type ButtonType = "submit" | "button" | "reset"; +``` + +### HTML Content + +You can render HTML content for a overflow item icon by using the `encodeURI()` function. [Learn more about URI encoding on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI). + +```javascript +html` + ')}","label":"Example 1","id":"example-1"}]' + > +` +``` + +### Event Listeners + +The `action` event will fire when the user clicks on one of the buttons. + +```typescript +document.body.querySelector('split-button').addEventListener('action', (e) => { + console.error(e.detail.id); +}); +``` diff --git a/client/src/framework/components/buttons/split-button/split-button.scss b/client/src/framework/components/buttons/split-button/split-button.scss new file mode 100644 index 0000000..869f02d --- /dev/null +++ b/client/src/framework/components/buttons/split-button/split-button.scss @@ -0,0 +1,129 @@ +split-button { + display: inline-flex; + justify-content: center; + align-items: center; + flex-flow: row nowrap; + position: relative; + background-color: transparent; + transition: transform 80ms var(--ease-in-out), box-shadow 80ms var(--ease-in-out); + box-shadow: none; + text-transform: uppercase; + font-size: var(--font-sm); + font-weight: var(--font-medium); + user-select: none; + text-overflow: ellipsis; + cursor: pointer; + line-height: 1; + outline-offset: 0; + box-shadow: var(--button-shadow); + + &::before { + content: ""; + display: inline-block; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + transition: all 80ms var(--ease-in-out); + border-radius: 0.25rem; + } + + &:disabled, + &[disabled] { + border-color: var(--grey-200) !important; + background-color: var(--grey-50) !important; + color: var(--grey-400) !important; + cursor: not-allowed !important; + box-shadow: none !important; + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + transition: outline-offset 80ms var(--ease-in-out); + } + + & > button, + & > .split { + &:hover { + background-color: var(--grey-50); + } + &:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + outline-color: var(--grey-900); + transition: outline-offset 80ms var(--ease-in-out); + } + &:active { + outline-offset: 0; + background-color: var(--grey-100); + } + + @media (prefers-color-scheme: dark) { + &:hover { + background-color: var(--grey-400); + border-color: var(--grey-200); + } + &:focus-visible { + border-color: var(--grey-200); + outline-color: var(--grey-400); + } + &:active { + box-shadow: none; + background-color: var(--grey-400); + } + } + } + + & > button { + background-color: var(--white); + border-radius: 0.25rem 0 0 0.25rem; + color: var(--grey-700); + background: var(--white); + border-width: 1px; + border-style: solid; + border-color: var(--grey-300); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 1rem; + height: 36px; + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-400); + background-color: var(--grey-400); + color: var(--grey-950); + border-width: 2px; + } + + &.split { + border-radius: 0 0.25rem 0.25rem 0; + border-style: solid; + border-color: var(--grey-300); + width: 36px; + padding: 0; + border-width: 1px 1px 1px 0; + + @media (prefers-color-scheme: dark) { + border-width: 2px; + border-color: var(--grey-400); + color: var(--grey-950); + } + + &::before { + border-radius: 0 0.25rem 0.25rem 0; + } + + &:hover, + &:focus-visible { + z-index: 2; + } + + svg { + width: 16px; + height: 16px; + } + } + } +} diff --git a/client/src/framework/components/buttons/split-button/split-button.ts b/client/src/framework/components/buttons/split-button/split-button.ts new file mode 100644 index 0000000..e639111 --- /dev/null +++ b/client/src/framework/components/buttons/split-button/split-button.ts @@ -0,0 +1,130 @@ +import { html, render, TemplateResult } from "lit-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import OverflowMenu, { OverflowItem } from "~brixi/components/overflow-menu/overflow-menu"; +import { UUID } from "@codewithkyle/uuid"; +import Component from "~brixi/component"; +import type { ButtonType } from "../button/button"; + +env.css(["split-button", "button"]); + +export interface ISplitButton { + type: ButtonType; + label: string; + icon?: string; + buttons: OverflowItem[]; + id: string; +} +export default class SplitButton extends Component { + private uid: string; + + constructor() { + super(); + this.uid = UUID(); + this.model = { + type: "button", + label: "", + buttons: [], + icon: "", + id: "", + }; + } + + static get observedAttributes() { + return ["data-type", "data-label", "data-buttons", "data-icon"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + private hideMenu = () => { + const buttonMenu: HTMLElement = this.querySelector("button-menu"); + if (buttonMenu) { + buttonMenu.style.visibility = "hidden"; + } + }; + + private handlePrimaryClick = () => { + this.dispatchEvent( + new CustomEvent("action", { + detail: { + id: this.model.id, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private openMenu = () => { + new OverflowMenu({ + target: this, + uid: this.uid, + items: this.model.buttons, + offset: 4, + callback: (id: string) => { + this.dispatchEvent( + new CustomEvent("action", { + detail: { + id, + }, + bubbles: true, + cancelable: true, + }) + ); + }, + }); + }; + + private renderIcon(icon: string): string | TemplateResult { + let out: string | TemplateResult; + if (icon?.length) { + out = html` ${unsafeHTML(icon)} `; + } else { + out = ""; + } + return out; + } + + private renderLabel(label: string): string | TemplateResult { + let out: string | TemplateResult; + if (label) { + out = html` ${label} `; + } else { + out = ""; + } + return out; + } + + private renderPrimaryButton() { + return html` + + `; + } + + private renderMenuButtons(): string | TemplateResult { + let out: string | TemplateResult; + if (this.model.buttons.length) { + out = html` + + `; + } else { + out = ""; + } + return out; + } + + override render() { + const view = html` ${this.renderPrimaryButton()} ${this.renderMenuButtons()} `; + render(view, this); + } +} +env.bind("split-button", SplitButton); diff --git a/client/src/framework/components/buttons/split-button/static.html b/client/src/framework/components/buttons/split-button/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/buttons/submit-button/index.html b/client/src/framework/components/buttons/submit-button/index.html new file mode 100644 index 0000000..a7cfc4b --- /dev/null +++ b/client/src/framework/components/buttons/submit-button/index.html @@ -0,0 +1,15 @@ + + + diff --git a/client/src/framework/components/buttons/submit-button/readme.md b/client/src/framework/components/buttons/submit-button/readme.md new file mode 100644 index 0000000..a24b700 --- /dev/null +++ b/client/src/framework/components/buttons/submit-button/readme.md @@ -0,0 +1,41 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| label | string | | +| submittingLabel | string | | +| size | ButtonSize | | +| icon | string | | +| disabled | boolean | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type ButtonSize = "default" | "slim" | "large"; +``` + +### Event Listeners + +The `submit` event will fire when the user clicks on one of the buttons. + +```typescript +document.body.querySelector("submit-button").addEventListener("submit", (e) => { + const el = e.currentTarget; + el.trigger("START"); // Starts the submitting animation + setTimeout(() => { + el.trigger("STOP"); // Stops the animation + }, 5000); +}); +``` diff --git a/client/src/framework/components/buttons/submit-button/static.html b/client/src/framework/components/buttons/submit-button/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/buttons/submit-button/submit-button.scss b/client/src/framework/components/buttons/submit-button/submit-button.scss new file mode 100644 index 0000000..5d4af6d --- /dev/null +++ b/client/src/framework/components/buttons/submit-button/submit-button.scss @@ -0,0 +1,4 @@ +submit-button { + display: inline-block; + position: relative; +} diff --git a/client/src/framework/components/buttons/submit-button/submit-button.ts b/client/src/framework/components/buttons/submit-button/submit-button.ts new file mode 100644 index 0000000..a44e95e --- /dev/null +++ b/client/src/framework/components/buttons/submit-button/submit-button.ts @@ -0,0 +1,105 @@ +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import "~brixi/components/progress/spinner/spinner"; +import Component from "~brixi/component"; +import type { ButtonSize } from "../button/button"; + +env.css(["submit-button", "button"]); + +export interface ISubmitButton { + label: string; + icon: string; + size: ButtonSize; + disabled: boolean; + submittingLabel: string; +} + +export default class SubmitButton extends Component { + constructor() { + super(); + this.state = "IDLING"; + this.stateMachine = { + IDLING: { + START: "SUBMITTING", + }, + SUBMITTING: { + START: "SUBMITTING", + STOP: "IDLING", + }, + }; + this.model = { + label: "Submit", + submittingLabel: "", + size: "default", + icon: "", + disabled: false, + }; + } + + static get observedAttributes() { + return ["data-label", "data-size", "data-icon", "data-disabled"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + private handleClick = () => { + if (this.state !== "SUBMITTING") { + this.dispatchEvent( + new CustomEvent("submit", { + bubbles: true, + cancelable: true, + }) + ); + } + }; + + private renderIcon(): string | TemplateResult { + let icon: string | TemplateResult = ""; + if (this.state === "SUBMITTING") { + icon = html` `; + } else if (this.model.icon?.length) { + icon = html`${unsafeHTML(this.model.icon)}`; + } else { + icon = ""; + } + return icon; + } + + private renderLabel(): string | TemplateResult { + let label: string | TemplateResult = ""; + if (this.state === "SUBMITTING" && this.model.submittingLabel?.length) { + label = html`${this.model.submittingLabel}`; + } else if (this.model.label?.length) { + label = html`${this.model.label}`; + } else { + label = ""; + } + return label; + } + + override render() { + this.setAttribute("state", this.state); + const view = html` + + `; + render(view, this); + } +} +env.bind("submit-button", SubmitButton); diff --git a/client/src/framework/components/buttons/toggle-button/index.html b/client/src/framework/components/buttons/toggle-button/index.html new file mode 100644 index 0000000..e334e5a --- /dev/null +++ b/client/src/framework/components/buttons/toggle-button/index.html @@ -0,0 +1,15 @@ + + + diff --git a/client/src/framework/components/buttons/toggle-button/readme.md b/client/src/framework/components/buttons/toggle-button/readme.md new file mode 100644 index 0000000..a6e204e --- /dev/null +++ b/client/src/framework/components/buttons/toggle-button/readme.md @@ -0,0 +1,65 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| state | string | ✅ | +| states | string[] | ✅ | +| buttons | Buttons | ✅ | +| instructions | string | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type Buttons = { + [state:string]: Button; +}; +type Button = { + label: string; + icon: string; + iconPosition: ButtonPosition; + kind: ButtonKind; + color: ButtonColor; + shape: ButtonShape; + size: ButtonSize; + type: ButtonType; +} +type ButtonKind = "solid" | "outline" | "text"; +type ButtonColor = "primary" | "danger" | "grey" | "success" | "warning" | "white"; +type ButtonShape = "pill" | "round" | "sharp" | "default"; +type ButtonSize = "default" | "slim" | "large"; +type ButtonType = "submit" | "button" | "reset"; +type ButtonPosition = "left" | "right" | "center"; +``` + +### HTML Content + +You can render HTML content for a button icon by using the `encodeURI()` function. [Learn more about URI encoding on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI). + +```javascript +html` + ')}","label":"Delete","color":"grey","kind":"solid","id":"delete"}}' + > +` +``` + +### Event Listeners + +The `action` event will fire when the user clicks on one of the buttons. + +```typescript +document.body.querySelector('toggle-button').addEventListener('action', (e) => { + console.log(e.detail.id); +}); +``` diff --git a/client/src/framework/components/buttons/toggle-button/static.html b/client/src/framework/components/buttons/toggle-button/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/buttons/toggle-button/toggle-button.scss b/client/src/framework/components/buttons/toggle-button/toggle-button.scss new file mode 100644 index 0000000..f2d358b --- /dev/null +++ b/client/src/framework/components/buttons/toggle-button/toggle-button.scss @@ -0,0 +1,27 @@ +toggle-button { + display: flex; + align-items: center; + flex-flow: column wrap; + justify-content: space-between; + width: 100%; + position: relative; + + @media (min-width: 768px) { + flex-flow: row nowrap; + } + + p { + display: block; + width: 100%; + flex: 1; + display: block; + margin-bottom: 1rem; + line-height: 1.375; + text-align: center; + + @media (min-width: 768px) { + text-align: left; + margin-bottom: auto; + } + } +} diff --git a/client/src/framework/components/buttons/toggle-button/toggle-button.ts b/client/src/framework/components/buttons/toggle-button/toggle-button.ts new file mode 100644 index 0000000..6e4e96f --- /dev/null +++ b/client/src/framework/components/buttons/toggle-button/toggle-button.ts @@ -0,0 +1,98 @@ +import { html, render, TemplateResult } from "lit-html"; +import env from "~brixi/controllers/env"; +import "~brixi/components/buttons/button/button"; +import { parseDataset } from "~brixi/utils/general"; +import type { IButton } from "~brixi/components/buttons/button/button"; +import Component from "~brixi/component"; + +env.css(["toggle-button", "button"]); + +interface Button extends IButton { + id: string; +} +export interface IToggleButton { + state: string; + states: Array; + buttons: { + [state: string]: Button; + }; + instructions: string; + index: number; +} +export default class ToggleButton extends Component { + constructor() { + super(); + this.model = { + state: null, + states: [], + buttons: {}, + instructions: "", + index: 0, + }; + } + + static get observedAttributes() { + return ["data-state", "data-states", "data-buttons", "data-instructions", "data-index"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + private handleAction: EventListener = (e: CustomEvent) => { + e.stopImmediatePropagation(); + }; + + private handleClick: EventListener = () => { + this.dispatchEvent( + new CustomEvent("action", { + detail: { + id: this.model.buttons[this.model.states[this.model.index]].id, + }, + bubbles: true, + cancelable: true, + }) + ); + const updated = this.get(); + updated.index++; + if (updated.index >= updated.states.length) { + updated.index = 0; + } + updated.state = updated.states[updated.index]; + this.set(updated); + }; + + private renderButton() { + const button = this.model.buttons[this.model.state]; + return html` + + `; + } + + private renderInstructions() { + let out: string | TemplateResult; + if (this.model.instructions.length) { + out = html`

${this.model.instructions}

`; + } else { + out = ""; + } + return out; + } + + override render() { + const view = html` ${this.renderInstructions()} ${this.renderButton()} `; + render(view, this); + } +} +env.bind("toggle-button", ToggleButton); diff --git a/client/src/framework/components/checkbox-group/checkbox-group.scss b/client/src/framework/components/checkbox-group/checkbox-group.scss new file mode 100644 index 0000000..efd74f0 --- /dev/null +++ b/client/src/framework/components/checkbox-group/checkbox-group.scss @@ -0,0 +1,53 @@ +checkbox-group { + display: block; + width: 100%; + position: relative; + + &[state="DISABLED"] { + p { + opacity: 0.6; + color: var(--grey-400) !important; + + strong { + color: var(--grey-400) !important; + } + } + + @media (prefers-color-scheme: dark) { + p { + color: var(--grey-300) !important; + opacity: 0.3; + + strong { + color: var(--grey-300) !important; + } + } + } + } + + p { + display: block; + margin-bottom: 0.5rem; + font-size: var(--font-xs); + color: var(--grey-700); + line-height: 1.375; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + + strong { + display: block; + width: 100%; + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--grey-800); + margin-bottom: 0.5rem; + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + } +} diff --git a/client/src/framework/components/checkbox-group/checkbox-group.ts b/client/src/framework/components/checkbox-group/checkbox-group.ts new file mode 100644 index 0000000..554de97 --- /dev/null +++ b/client/src/framework/components/checkbox-group/checkbox-group.ts @@ -0,0 +1,108 @@ +import { html, render } from "lit-html"; +import env from "~brixi/controllers/env"; +import type { ICheckbox } from "~brixi/components/checkbox/checkbox"; +import "~brixi/components/checkbox/checkbox"; +import { parseDataset } from "~brixi/utils/general"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import soundscape from "~brixi/controllers/soundscape"; +import Component from "~brixi/component"; + +env.css("checkbox-group"); + +export interface ICheckboxGroup { + options: Array; + instructions: string; + disabled: boolean; + label: string; + name: string; +} +export default class CheckboxGroup extends Component { + constructor() { + super(); + this.model = { + label: "", + instructions: "", + disabled: false, + name: "", + options: [], + }; + } + + static get observedAttributes() { + return ["data-label", "data-instructions", "data-disabled", "data-name", "data-options"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + settings.options.map((option) => { + option.disabled = settings?.disabled ?? false; + }); + this.state = settings.disabled ? "DISABLED" : "IDLING"; + this.set(settings); + } + + public getName(): string { + return this.model.name; + } + + public getValue(): Array { + let values = []; + this.querySelectorAll("checkbox-component").forEach((checkbox) => { + // @ts-ignore + const value = checkbox.getValue(); + if (value) { + values.push(value); + } + }); + return values; + } + + public reset(): void { + const updated = this.get(); + for (let i = 0; i < updated.options.length; i++) { + updated.options[i].checked = false; + } + this.set(updated); + } + + public clearError(): void { + if (this.state === "ERROR") { + this.trigger("RESET"); + } + } + + public setError(error: string): void { + if (error?.length) { + this.set({ + // @ts-ignore + error: error, + }); + this.trigger("ERROR"); + soundscape.play("error"); + } + } + + override render() { + this.setAttribute("state", this.state); + this.setAttribute("form-input", ""); + const view = html` +

+ ${this.model.label} + ${unsafeHTML(this.model.instructions)} +

+ ${this.model.options.map((option: ICheckbox) => { + return html` + + `; + })} + `; + render(view, this); + } +} +env.bind("checkbox-group", CheckboxGroup); diff --git a/client/src/framework/components/checkbox-group/index.html b/client/src/framework/components/checkbox-group/index.html new file mode 100644 index 0000000..7e2a269 --- /dev/null +++ b/client/src/framework/components/checkbox-group/index.html @@ -0,0 +1,22 @@ + + + + + + diff --git a/client/src/framework/components/checkbox-group/readme.md b/client/src/framework/components/checkbox-group/readme.md new file mode 100644 index 0000000..728a162 --- /dev/null +++ b/client/src/framework/components/checkbox-group/readme.md @@ -0,0 +1,56 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| options | Checkbox[] | ✅ | +| label | string | | +| instructions | string | | +| disabled | boolean | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type Checkbox = { + label: string; + value: string; + checked?: boolean; + disabled?: boolean; +}; +``` + +### Event Listeners + +The `change` event will fire when the checkbox value changes. + +```typescript +document.body.querySelector('checkbox-group').addEventListener('change', (e) => { + const { checked, name, value } = e.detail; +}); +``` + +### Querying Form Inputs + +All form inputs can be queried using the `[form-input]` attribute. + +```typescript +document.body.querySelectorAll("[form-input]").forEach(el => { + const name = el.getName(); + const value = el.getValue(); + const isValid = el.validate(); + el.setError("Set a custom error message"); + el.clearError(); +}); +``` diff --git a/client/src/framework/components/checkbox-group/static.html b/client/src/framework/components/checkbox-group/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/checkbox/checkbox.scss b/client/src/framework/components/checkbox/checkbox.scss new file mode 100644 index 0000000..589c05d --- /dev/null +++ b/client/src/framework/components/checkbox/checkbox.scss @@ -0,0 +1,149 @@ +.checkbox { + display: block; + width: 100%; + position: relative; + + &.is-active { + label { + check-box { + i { + box-shadow: none; + transform: translateY(1px); + outline-offset: 0 !important; + } + } + } + } + + input { + position: absolute; + top: 0; + left: 0; + opacity: 0; + visibility: hidden; + + &:checked + label { + check-box i { + background-color: var(--primary-700); + border-color: var(--primary-800); + + @media (prefers-color-scheme: dark) { + background-color: var(--primary-500); + border-color: var(--primary-500); + } + + svg { + opacity: 1; + visibility: visible; + } + } + } + + &:disabled + label { + cursor: not-allowed !important; + + check-box i { + background-color: var(--grey-50) !important; + border-color: var(--grey-200) !important; + box-shadow: none !important; + transform: none !important; + cursor: not-allowed !important; + + @media (prefers-color-scheme: dark) { + background-color: transparent !important; + border-color: var(--grey-700) !important; + } + } + + span { + color: var(--grey-400) !important; + + @media (prefers-color-scheme: dark) { + color: var(--grey-500) !important; + } + } + } + } + + label { + display: flex; + align-items: center; + width: 100%; + min-height: 36px; + cursor: pointer; + + &:active { + check-box i { + box-shadow: none; + transform: translateY(1px); + } + } + + check-box { + width: 24px; + height: 24px; + display: inline-flex; + justify-content: center; + align-items: center; + cursor: pointer; + + &:active { + i { + box-shadow: none; + transform: translateY(1px); + } + } + + &:focus-visible { + i { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + transition: outline-offset 80ms var(--ease-in-out); + } + } + + i { + transition: all 80ms var(--ease-in-out); + width: 16px; + height: 16px; + display: inline-flex; + justify-content: center; + align-items: center; + color: var(--white); + border-radius: 0.25rem; + border: var(--input-border); + background-color: var(--white); + box-shadow: 0 1px 0 hsl(0deg 0% 0% / 0.1), inset 0 -2px 0 hsl(0deg 0% 0% / 0.05); + outline-offset: 0; + + @media (prefers-color-scheme: dark) { + background-color: transparent; + border-color: var(--grey-600); + } + + svg { + width: 14px; + height: 14px; + visibility: hidden; + opacity: 0; + color: var(--white) !important; + padding: 0 !important; + margin: 0 !important; + } + } + } + + span { + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--grey-800); + user-select: none; + margin-left: 0.5rem; + margin-top: 2px; + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + } +} diff --git a/client/src/framework/components/checkbox/checkbox.ts b/client/src/framework/components/checkbox/checkbox.ts new file mode 100644 index 0000000..859c99b --- /dev/null +++ b/client/src/framework/components/checkbox/checkbox.ts @@ -0,0 +1,191 @@ +import { html, render, TemplateResult } from "lit-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import soundscape from "~brixi/controllers/soundscape"; +import Component from "~brixi/component"; +import { UUID } from "@codewithkyle/uuid"; + +env.css("checkbox"); + +export interface ICheckbox { + label: string; + required: boolean; + name: string; + checked: boolean; + error: string; + disabled: boolean; + type: "check" | "line"; + value: string | number; +} + +export default class Checkbox extends Component { + constructor() { + super(); + this.id = UUID(); + this.state = "IDLING"; + this.stateMachine = { + IDLING: { + ERROR: "ERROR", + DISABLE: "DISABLED", + }, + ERROR: { + RESET: "IDLING", + ERROR: "ERROR", + }, + DISABLED: { + ENABLE: "IDLING", + }, + }; + this.model = { + label: "", + required: false, + name: "", + checked: false, + error: "", + disabled: false, + type: "check", + value: null, + }; + } + + static get observedAttributes() { + return ["data-label", "data-required", "data-name", "data-checked", "data-disabled", "data-type", "data-value"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + if (settings?.disabled) { + this.state = "DISABLED"; + } + this.set(settings); + this.addEventListener("click", this.handleChange); + } + + private handleChange: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + if (this.model.disabled) return; + const isChecked = !this.model.checked; + this.set({ + checked: isChecked, + }); + this.dispatchEvent(new CustomEvent("change", { detail: { checked: isChecked, name: this.model.name, value: this.model.value }, bubbles: true, cancelable: true })); + if (isChecked) { + soundscape.play("click"); + } else { + soundscape.play("hover"); + } + }; + + private handleKeydown: EventListener = (e: KeyboardEvent) => { + if (e.key === " ") { + this.classList.add("is-active"); + } + }; + + private handleKeyup: EventListener = (e: KeyboardEvent) => { + if (e.key === " ") { + this.classList.remove("is-active"); + const target = this.querySelector("input") as HTMLInputElement; + const isChecked = !target.checked; + this.set({ + checked: isChecked, + }); + this.dispatchEvent(new CustomEvent("change", { detail: { checked: isChecked, name: target.name }, bubbles: true, cancelable: true })); + if (isChecked) { + soundscape.play("click"); + } else { + soundscape.play("hover"); + } + } + }; + + public getName(): string { + return this.model.name; + } + + public getValue(): string | number | null { + if (this.model.checked) { + return this.model.value; + } else { + return null; + } + } + + public reset(): void { + this.set({ + checked: false, + }); + } + + public clearError(): void { + if (this.state === "ERROR") { + this.trigger("RESET"); + } + } + + public setError(error: string): void { + if (error?.length) { + this.set({ + // @ts-ignore + error: error, + }); + this.trigger("ERROR"); + soundscape.play("error"); + } + } + + public validate(): boolean { + let isValid = true; + if (this.model.required && !this.model.checked) { + isValid = false; + this.setError("This field is required"); + } + return isValid; + } + + private renderIcon(): TemplateResult { + switch (this.model.type) { + case "line": + return html` + + `; + default: + return html` + + `; + } + } + + render() { + this.setAttribute("state", this.state); + this.setAttribute("form-input", ""); + this.classList.add("checkbox"); + const view = html` +
+ + +
+ `; + render(view, this); + } +} +env.bind("checkbox-component", Checkbox); diff --git a/client/src/framework/components/checkbox/index.html b/client/src/framework/components/checkbox/index.html new file mode 100644 index 0000000..9ae429a --- /dev/null +++ b/client/src/framework/components/checkbox/index.html @@ -0,0 +1,19 @@ + + + + + + + diff --git a/client/src/framework/components/checkbox/readme.md b/client/src/framework/components/checkbox/readme.md new file mode 100644 index 0000000..39d425b --- /dev/null +++ b/client/src/framework/components/checkbox/readme.md @@ -0,0 +1,49 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| label | string | | +| required | boolean | | +| checked | boolean | | +| disabled | boolean | | +| type | "check" or "line" | | +| value | string or number | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Event Listeners + +The `change` event will fire when the checkbox value changes. + +```typescript +document.body.querySelector('checkbox-component').addEventListener('change', (e) => { + const { checked, name } = e.detail; +}); +``` + +### Querying Form Inputs + +All form inputs can be queried using the `[form-input]` attribute. + +```typescript +document.body.querySelectorAll("[form-input]").forEach(el => { + const name = el.getName(); + const value = el.getValue(); + const isValid = el.validate(); + el.setError("Set a custom error message"); + el.clearError(); +}); +``` diff --git a/client/src/framework/components/checkbox/static.html b/client/src/framework/components/checkbox/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/chips/assist-chip/assist-chip.scss b/client/src/framework/components/chips/assist-chip/assist-chip.scss new file mode 100644 index 0000000..11510f2 --- /dev/null +++ b/client/src/framework/components/chips/assist-chip/assist-chip.scss @@ -0,0 +1,83 @@ +assist-chip { + display: inline-flex; + align-items: center; + justify-content: center; + flex-flow: row nowrap; + position: relative; + height: 32px; + border-radius: 0.5rem; + background-color: var(--white); + border: 1px solid var(--grey-300); + color: var(--grey-700); + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: 0 0.75rem 0 0.5rem; + transition: all 80ms var(--ease-in-out); + cursor: pointer; + user-select: none; + + &:hover { + background-color: var(--grey-50); + } + + &:active, + &.is-active { + background-color: var(--grey-100); + outline-offset: 0 !important; + transition: outline-offset 0ms linear; + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 5px; + } + + @media (prefers-color-scheme: dark) { + border: none; + background-color: hsl(var(--grey-950-hsl) / 0.6); + color: var(--grey-300); + + &::before { + content: ""; + position: absolute; + border-radius: 0.5rem; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--grey-300); + opacity: 0; + } + + &:hover, + &:focus-visible { + color: var(--grey-100); + background-color: hsl(var(--grey-950-hsl) / 0.6); + + &::before { + opacity: 0.05; + } + } + + &:active, + &.is-active { + color: var(--white); + background-color: hsl(var(--grey-950-hsl) / 0.6); + + &::before { + opacity: 0.1; + } + } + } + + svg { + width: 18px; + height: 18px; + color: var(--grey-600); + margin-right: 0.5rem; + } + + span { + line-height: 1; + } +} diff --git a/client/src/framework/components/chips/assist-chip/assist-chip.ts b/client/src/framework/components/chips/assist-chip/assist-chip.ts new file mode 100644 index 0000000..754755e --- /dev/null +++ b/client/src/framework/components/chips/assist-chip/assist-chip.ts @@ -0,0 +1,64 @@ +import { html, render } from "lit-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import Component from "~brixi/component"; + +env.css(["assist-chip"]); + +export interface IAssistChip { + label: string; + icon: string; +} +export default class AssistChip extends Component { + constructor() { + super(); + this.model = { + label: "", + icon: "", + }; + } + + static get observedAttributes() { + return ["data-label", "data-icon"]; + } + + override connected(): void { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + this.addEventListener("keyup", this.handleKeyup); + this.addEventListener("keydown", this.handleKeydown); + } + + private handleKeydown = (e: KeyboardEvent) => { + if (e.key === " ") { + this.classList.add("is-active"); + } + }; + + private handleKeyup = (e: KeyboardEvent) => { + if (e.key === " ") { + this.classList.remove("is-active"); + this.click(); + } + }; + + private renderIcon() { + let out: any = ""; + if (this.model.icon?.length) { + out = unsafeHTML(this.model.icon); + } + return out; + } + + override render() { + this.tabIndex = 0; + this.setAttribute("role", "button"); + const view = html` + ${this.renderIcon()} + ${this.model.label} + `; + render(view, this); + } +} +env.bind("assist-chip", AssistChip); diff --git a/client/src/framework/components/chips/assist-chip/index.html b/client/src/framework/components/chips/assist-chip/index.html new file mode 100644 index 0000000..99f7919 --- /dev/null +++ b/client/src/framework/components/chips/assist-chip/index.html @@ -0,0 +1,13 @@ + + + + + diff --git a/client/src/framework/components/chips/assist-chip/readme.md b/client/src/framework/components/chips/assist-chip/readme.md new file mode 100644 index 0000000..1463942 --- /dev/null +++ b/client/src/framework/components/chips/assist-chip/readme.md @@ -0,0 +1,25 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| label | string | ✅ | +| icon | string | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Event Listeners + +The `click` event will fire when the user clicks on the chip. + +```typescript +document.querySelector('assist-chip').addEventListener('click', () => { + console.log('Chip clicked'); +}); +``` diff --git a/client/src/framework/components/chips/assist-chip/static.html b/client/src/framework/components/chips/assist-chip/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/chips/filter-chip/filter-chip.scss b/client/src/framework/components/chips/filter-chip/filter-chip.scss new file mode 100644 index 0000000..77dd328 --- /dev/null +++ b/client/src/framework/components/chips/filter-chip/filter-chip.scss @@ -0,0 +1,125 @@ +filter-chip { + display: inline-block; + position: relative; + + &.is-active { + label { + background-color: var(--grey-100); + outline-offset: 0 !important; + transition: outline-offset 0ms linear; + } + } + + input { + opacity: 0; + position: absolute; + top: 0; + left: 0; + visibility: hidden; + + &:checked + label { + background-color: var(--primary-100); + color: var(--primary-700); + border-color: var(--primary-100) !important; + padding-left: 0.5rem; + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--primary-500-hsl) / 0.05); + color: var(--primary-300); + border: none !important; + } + + svg { + display: inline-block !important; + } + } + } + + label { + display: inline-flex; + align-items: center; + justify-content: center; + flex-flow: row nowrap; + position: relative; + height: 32px; + border-radius: 0.5rem; + background-color: var(--white); + border: 1px solid var(--grey-300); + color: var(--grey-700); + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: 0 0.75rem; + transition: all 80ms var(--ease-in-out); + cursor: pointer; + user-select: none; + + &:hover { + background-color: var(--grey-50); + } + + &:active { + background-color: var(--grey-100); + outline-offset: 0 !important; + transition: outline-offset 0ms linear; + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 5px; + } + + @media (prefers-color-scheme: dark) { + border: none; + background-color: hsl(var(--grey-950-hsl) / 0.6); + color: var(--grey-300); + + &::before { + content: ""; + position: absolute; + border-radius: 0.5rem; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--grey-300); + opacity: 0; + } + + &:hover, + &:focus-visible { + color: var(--grey-100); + background-color: hsl(var(--grey-950-hsl) / 0.6); + + &::before { + opacity: 0.05; + } + } + + &:active, + &.is-active { + color: var(--white); + background-color: hsl(var(--grey-950-hsl) / 0.6); + + &::before { + opacity: 0.1; + } + } + } + + svg { + display: none; + width: 18px; + height: 18px; + color: var(--primary-600); + margin-right: 0.5rem; + + @media (prefers-color-scheme: dark) { + color: var(--primary-300); + } + } + + span { + line-height: 1; + } + } +} diff --git a/client/src/framework/components/chips/filter-chip/filter-chip.ts b/client/src/framework/components/chips/filter-chip/filter-chip.ts new file mode 100644 index 0000000..4807f64 --- /dev/null +++ b/client/src/framework/components/chips/filter-chip/filter-chip.ts @@ -0,0 +1,86 @@ +import { html, render } from "lit-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import Component from "~brixi/component"; +import { UUID } from "@codewithkyle/uuid"; + +env.css(["filter-chip"]); + +export interface IFilterChip { + label: string; + value: string | number; + checked: boolean; +} +export default class FilterChip extends Component { + constructor() { + super(); + this.id = UUID(); + this.model = { + label: "", + value: null, + checked: false, + }; + } + + static get observedAttributes() { + return ["data-label", "data-value", "data-checked"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + private handleClick = () => { + const isChecked = !this.model.checked; + this.set({ checked: isChecked }); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + checked: isChecked, + value: this.model.value, + }, + }) + ); + }; + + private handleKeydown = (e: KeyboardEvent) => { + if (e.key === " ") { + this.classList.add("is-active"); + } + }; + + private handleKeyup = (e: KeyboardEvent) => { + if (e.key === " ") { + this.classList.remove("is-active"); + this.click(); + } + }; + + override render() { + this.setAttribute("role", "button"); + this.setAttribute("sfx", "button"); + const view = html` + + + `; + render(view, this); + } +} +env.bind("filter-chip", FilterChip); diff --git a/client/src/framework/components/chips/filter-chip/index.html b/client/src/framework/components/chips/filter-chip/index.html new file mode 100644 index 0000000..e2af742 --- /dev/null +++ b/client/src/framework/components/chips/filter-chip/index.html @@ -0,0 +1,27 @@ +
+ + + +
+ + + diff --git a/client/src/framework/components/chips/filter-chip/readme.md b/client/src/framework/components/chips/filter-chip/readme.md new file mode 100644 index 0000000..a499bc2 --- /dev/null +++ b/client/src/framework/components/chips/filter-chip/readme.md @@ -0,0 +1,25 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| label | string | ✅ | +| value | string | ✅ | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Event Listeners + +The `change` event will fire when the user clicks the chip. + +```typescript +document.body.querySelector('filter-chip').addEventListener('change', (e) => { + const { checked, value } = e.detail; +}); +``` diff --git a/client/src/framework/components/chips/filter-chip/static.html b/client/src/framework/components/chips/filter-chip/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/chips/input-chip/index.html b/client/src/framework/components/chips/input-chip/index.html new file mode 100644 index 0000000..365ff81 --- /dev/null +++ b/client/src/framework/components/chips/input-chip/index.html @@ -0,0 +1,12 @@ + + + + diff --git a/client/src/framework/components/chips/input-chip/input-chip.scss b/client/src/framework/components/chips/input-chip/input-chip.scss new file mode 100644 index 0000000..96986e1 --- /dev/null +++ b/client/src/framework/components/chips/input-chip/input-chip.scss @@ -0,0 +1,95 @@ +input-chip { + display: inline-flex; + align-items: center; + justify-content: center; + flex-flow: row nowrap; + position: relative; + height: 32px; + border-radius: 0.5rem; + background-color: var(--white); + border: 1px solid var(--grey-300); + color: var(--grey-700); + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: 0 0.75rem; + transition: all 80ms var(--ease-in-out); + cursor: pointer; + user-select: none; + + &:hover { + background-color: var(--grey-50); + + svg { + color: var(--grey-600); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + } + + &:active, + &.is-active { + background-color: var(--grey-100); + outline-offset: 0 !important; + transition: outline-offset 0ms linear; + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 5px; + } + + @media (prefers-color-scheme: dark) { + border: none; + background-color: hsl(var(--grey-950-hsl) / 0.6); + color: var(--grey-300); + + &::before { + content: ""; + position: absolute; + border-radius: 0.5rem; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--grey-300); + opacity: 0; + } + + &:hover, + &:focus-visible { + color: var(--grey-100); + background-color: hsl(var(--grey-950-hsl) / 0.6); + + &::before { + opacity: 0.05; + } + } + + &:active, + &.is-active { + color: var(--white); + background-color: hsl(var(--grey-950-hsl) / 0.6); + + &::before { + opacity: 0.1; + } + } + } + + svg { + width: 14px; + height: 14px; + color: var(--grey-400); + margin-left: 0.5rem; + + @media (prefers-color-scheme: dark) { + color: var(--grey-600); + } + } + + span { + line-height: 1; + } +} diff --git a/client/src/framework/components/chips/input-chip/input-chip.ts b/client/src/framework/components/chips/input-chip/input-chip.ts new file mode 100644 index 0000000..ad44b69 --- /dev/null +++ b/client/src/framework/components/chips/input-chip/input-chip.ts @@ -0,0 +1,84 @@ +import { html, render } from "lit-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import Component from "~brixi/component"; + +env.css(["input-chip"]); + +export interface IInputChip { + label: string; + value: string | number; +} +export default class InputChip extends Component { + constructor() { + super(); + this.model = { + label: "", + value: null, + }; + } + + static get observedAttributes() { + return ["data-label", "data-value"]; + } + + override connected(): void { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + this.addEventListener("click", this.handleClick); + this.addEventListener("keyup", this.handleKeyup); + this.addEventListener("keydown", this.handleKeydown); + } + + private handleClick = () => { + this.dispatchEvent( + new CustomEvent("remove", { + detail: { + value: this.model.value, + }, + bubbles: true, + cancelable: true, + }) + ); + this.remove(); + }; + + private handleKeydown = (e: KeyboardEvent) => { + if (e.key === " ") { + this.classList.add("is-active"); + } + }; + + private handleKeyup = (e: KeyboardEvent) => { + if (e.key === " ") { + this.classList.remove("is-active"); + this.click(); + } + }; + + override render() { + this.tabIndex = 0; + this.setAttribute("role", "button"); + this.setAttribute("sfx", "button"); + const view = html` + ${this.model.label} + + + + + + `; + render(view, this); + } +} +env.bind("input-chip", InputChip); diff --git a/client/src/framework/components/chips/input-chip/readme.md b/client/src/framework/components/chips/input-chip/readme.md new file mode 100644 index 0000000..cf5b44e --- /dev/null +++ b/client/src/framework/components/chips/input-chip/readme.md @@ -0,0 +1,25 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| label | string | ✅ | +| value | string or number | ✅ | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Event Listeners + +The `remove` event will fire when the user clicks to remove the chip. + +```typescript +document.querySelector('input-chip').addEventListener('remove', (event) => { + console.log(event.detail.value); +}); +``` diff --git a/client/src/framework/components/chips/input-chip/static.html b/client/src/framework/components/chips/input-chip/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/chips/suggestion-chip/index.html b/client/src/framework/components/chips/suggestion-chip/index.html new file mode 100644 index 0000000..398131e --- /dev/null +++ b/client/src/framework/components/chips/suggestion-chip/index.html @@ -0,0 +1,11 @@ + + + + diff --git a/client/src/framework/components/chips/suggestion-chip/readme.md b/client/src/framework/components/chips/suggestion-chip/readme.md new file mode 100644 index 0000000..86201d4 --- /dev/null +++ b/client/src/framework/components/chips/suggestion-chip/readme.md @@ -0,0 +1,25 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| label | string | ✅ | +| value | string or number | ✅ | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Event Listeners + +The `suggest` event will fire when the user clicks the chip. + +```typescript +document.querySelector('suggestion-chip').addEventListener('suggest', (event) => { + console.log(event.detail.value); +}); +``` diff --git a/client/src/framework/components/chips/suggestion-chip/static.html b/client/src/framework/components/chips/suggestion-chip/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/chips/suggestion-chip/suggestion-chip.scss b/client/src/framework/components/chips/suggestion-chip/suggestion-chip.scss new file mode 100644 index 0000000..8ce9899 --- /dev/null +++ b/client/src/framework/components/chips/suggestion-chip/suggestion-chip.scss @@ -0,0 +1,76 @@ +suggestion-chip { + display: inline-flex; + align-items: center; + justify-content: center; + flex-flow: row nowrap; + position: relative; + height: 32px; + border-radius: 0.5rem; + background-color: var(--white); + border: 1px solid var(--grey-300); + color: var(--grey-700); + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: 0 0.75rem; + transition: all 80ms var(--ease-in-out); + cursor: pointer; + user-select: none; + + &:hover { + background-color: var(--grey-50); + } + + &:active, + &.is-active { + background-color: var(--grey-100); + outline-offset: 0 !important; + transition: outline-offset 0ms linear; + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 5px; + } + + @media (prefers-color-scheme: dark) { + border: none; + background-color: hsl(var(--grey-950-hsl) / 0.6); + color: var(--grey-300); + + &::before { + content: ""; + position: absolute; + border-radius: 0.5rem; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--grey-300); + opacity: 0; + } + + &:hover, + &:focus-visible { + color: var(--grey-100); + background-color: hsl(var(--grey-950-hsl) / 0.6); + + &::before { + opacity: 0.05; + } + } + + &:active, + &.is-active { + color: var(--white); + background-color: hsl(var(--grey-950-hsl) / 0.6); + + &::before { + opacity: 0.1; + } + } + } + + span { + line-height: 1; + } +} diff --git a/client/src/framework/components/chips/suggestion-chip/suggestion-chip.ts b/client/src/framework/components/chips/suggestion-chip/suggestion-chip.ts new file mode 100644 index 0000000..b4eff65 --- /dev/null +++ b/client/src/framework/components/chips/suggestion-chip/suggestion-chip.ts @@ -0,0 +1,66 @@ +import { html, render } from "lit-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import Component from "~brixi/component"; + +env.css(["suggestion-chip"]); + +export interface ISuggestionChip { + label: string; + value: string | number; +} +export default class SuggestionChip extends Component { + constructor() { + super(); + this.model = { + label: "", + value: null, + }; + } + + static get observedAttributes() { + return ["data-label", "data-value"]; + } + + override connected(): void { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + this.addEventListener("click", this.handleClick); + this.addEventListener("keyup", this.handleKeyup); + this.addEventListener("keydown", this.handleKeydown); + } + + private handleClick = () => { + this.dispatchEvent( + new CustomEvent("suggest", { + detail: { + value: this.model.value, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleKeydown = (e: KeyboardEvent) => { + if (e.key === " ") { + this.classList.add("is-active"); + } + }; + + private handleKeyup = (e: KeyboardEvent) => { + if (e.key === " ") { + this.classList.remove("is-active"); + this.click(); + } + }; + + override render() { + this.tabIndex = 0; + this.setAttribute("role", "button"); + this.setAttribute("sfx", "button"); + const view = html` ${this.model.label} `; + render(view, this); + } +} +env.bind("suggestion-chip", SuggestionChip); diff --git a/client/src/framework/components/context-menu/context-menu.scss b/client/src/framework/components/context-menu/context-menu.scss new file mode 100644 index 0000000..544dc99 --- /dev/null +++ b/client/src/framework/components/context-menu/context-menu.scss @@ -0,0 +1,50 @@ +context-menu { + display: inline-block; + position: fixed; + top: 0; + left: 0; + background-color: var(--grey-800); + box-shadow: var(--shadow-black-sm); + border: 1px solid var(--grey-600); + padding: 0.5rem 0; + min-width: 200px; + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-800); + background-color: var(--grey-950); + } + + button { + white-space: nowrap; + display: flex; + justify-content: space-between; + align-items: center; + flex-flow: row nowrap; + padding: 0 1rem; + width: 100%; + height: 24px; + + &:hover, + &:focus-visible { + background-color: var(--primary-500); + } + + span { + display: inline-block; + font-size: var(--font-xs); + color: var(--white); + } + } + + hr { + display: block; + width: 100%; + height: 1px; + background-color: var(--grey-600); + margin: 0.5rem 0; + + @media (prefers-color-scheme: dark) { + background-color: var(--grey-800); + } + } +} diff --git a/client/src/framework/components/context-menu/context-menu.ts b/client/src/framework/components/context-menu/context-menu.ts new file mode 100644 index 0000000..ec5fbbb --- /dev/null +++ b/client/src/framework/components/context-menu/context-menu.ts @@ -0,0 +1,90 @@ +import { html, render, TemplateResult } from "lit-html"; +import SuperComponent from "@codewithkyle/supercomponent"; +import env from "~brixi/controllers/env"; +import pos from "~brixi/controllers/pos"; + +env.css(["context-menu"]); + +export interface ContextMenuItem { + label: string; + hotkey?: string; + callback: Function; +} +export interface IContextMenu { + items: ContextMenuItem[]; + x: number; + y: number; +} +export interface ContextMenuSettings { + items: ContextMenuItem[]; + x: number; + y: number; +} +export default class ContextMenu extends SuperComponent { + constructor(settings: ContextMenuSettings) { + super(); + this.model = { + items: [], + x: 0, + y: 0, + }; + this.set(settings); + } + + override connected() { + document.addEventListener( + "click", + () => { + this.remove(); + }, + { passive: true, capture: true } + ); + window.addEventListener( + "resize", + () => { + this.remove(); + }, + { passive: true, capture: true } + ); + window.addEventListener( + "scroll", + () => { + this.remove(); + }, + { passive: true, capture: true } + ); + this.addEventListener("click", (e: Event) => { + e.stopImmediatePropagation(); + }); + } + + private handleItemClick: EventListener = (e: Event) => { + const target = e.currentTarget as HTMLElement; + const index = parseInt(target.dataset.index); + if (this.model.items?.[index]?.callback && typeof this.model.items?.[index]?.callback === "function") { + this.model.items[index].callback(); + } + }; + + private renderItem(item: ContextMenuItem, index: number): TemplateResult { + if (item === null) { + return html`
`; + } + return html` + + `; + } + + override render() { + if (!this.isConnected) { + document.body.appendChild(this); + } + const view = html` ${this.model.items?.map((item, index) => this.renderItem(item, index))} `; + render(view, this); + pos.positionElement(this, this.model.x, this.model.y); + } +} +env.bind("context-menu", ContextMenu); diff --git a/client/src/framework/components/context-menu/index.html b/client/src/framework/components/context-menu/index.html new file mode 100644 index 0000000..e6836fc --- /dev/null +++ b/client/src/framework/components/context-menu/index.html @@ -0,0 +1,41 @@ + diff --git a/client/src/framework/components/context-menu/readme.md b/client/src/framework/components/context-menu/readme.md new file mode 100644 index 0000000..240a04a --- /dev/null +++ b/client/src/framework/components/context-menu/readme.md @@ -0,0 +1,61 @@ +```typescript +import ContextMenu from "~brixi/components/context-menu/context-menu"; +new ContextMenu({ + items: [ + { + label: "Back", + callback: () => { + console.log("Back"); + }, + }, + { + label: "Forward", + callback: () => { + console.log("Forward"); + }, + }, + { + label: "Reload", + hotkey: "Ctrl+R", + callback: () => { + location.reload(); + }, + }, + null, + { + label: "Action 1", + callback: () => { + console.log("Action 1"); + }, + }, + { + label: "Action 2", + callback: () => { + console.log("Action 2"); + }, + }, + ], + x: 24, // x offset from source pos + y: 24, // y offset from source pos +}); +``` + +> **Note**: you can render `null` instead of a `ContextItem` to render a horizontal rule. + +### Data Attributes + +| Key | Type | Required | +| --- | ---- | -------- | +| items | ContextItem[] | ✅ | +| x | number | | +| y | number | | + +### Types + +```typescript +type ContextItem = { + label: string; + hotkey?: string; + callback: Function; +} +``` diff --git a/client/src/framework/components/context-menu/static.html b/client/src/framework/components/context-menu/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/divider/divider.scss b/client/src/framework/components/divider/divider.scss new file mode 100644 index 0000000..6dc4520 --- /dev/null +++ b/client/src/framework/components/divider/divider.scss @@ -0,0 +1,144 @@ +divider-component { + display: flex; + align-items: center; + flex-flow: row nowrap; + position: relative; + width: 100%; + + &[line-style="dashed"] { + div { + border-style: dashed; + } + } + + &[line-style="solid"] { + div { + border-style: solid; + } + } + + &[line-style="dotted"] { + div { + border-style: dotted; + } + } + + &[color="primary"] { + div { + border-color: var(--primary-400); + } + span { + color: var(--primary-700); + + @media (prefers-color-scheme: dark) { + color: var(--primary-300); + } + } + } + + &[color="grey"] { + div { + border-color: var(--grey-300); + } + span { + color: var(--grey-700); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + } + + &[color="success"] { + div { + border-color: var(--success-400); + } + span { + color: var(--success-700); + + @media (prefers-color-scheme: dark) { + color: var(--success-300); + } + } + } + + &[color="warning"] { + div { + border-color: var(--warning-400); + } + span { + color: var(--warning-700); + + @media (prefers-color-scheme: dark) { + color: var(--warning-300); + } + } + } + + &[color="danger"] { + div { + border-color: var(--danger-400); + } + span { + color: var(--danger-700); + + @media (prefers-color-scheme: dark) { + color: var(--danger-300); + } + } + } + + &[color="black"] { + div { + border-color: var(--grey-700); + } + span { + color: var(--grey-900); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + } + + &[layout="vertical"] { + flex-flow: column wrap; + width: 24px; + height: 100%; + + div { + width: 1px; + height: 100%; + display: block; + border-width: 0 1px 0 0; + } + + span { + text-orientation: upright; + writing-mode: vertical-rl; + padding: 0.75rem 0; + font-size: var(--font-xs); + } + } + + div { + flex: 1; + width: 100%; + height: 1px; + border-width: 1px 0 0 0; + border-style: solid; + border-color: var(--grey-300); + } + + span { + color: var(--grey-700); + font-size: var(--font-sm); + padding: 0 0.75rem; + display: inline-block; + + svg { + width: 18px; + height: 18px; + } + } +} diff --git a/client/src/framework/components/divider/divider.ts b/client/src/framework/components/divider/divider.ts new file mode 100644 index 0000000..74253df --- /dev/null +++ b/client/src/framework/components/divider/divider.ts @@ -0,0 +1,53 @@ +import { html, render, TemplateResult } from "lit-html"; +import env from "~brixi/controllers/env"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import { parseDataset } from "~brixi/utils/general"; +import Component from "~brixi/component"; + +env.css(["divider"]); + +export type DividerColor = "primary" | "success" | "warning" | "danger" | "black" | "grey"; +export interface IDivider { + label: string; + color: DividerColor; + layout: "horizontal" | "vertical"; + type: "solid" | "dashed" | "dotted"; +} +export default class Divider extends Component { + constructor() { + super(); + this.model = { + label: "", + color: "grey", + layout: "horizontal", + type: "solid", + }; + } + + static get observedAttributes() { + return ["data-label", "data-color", "data-layout", "data-type"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + override render() { + this.setAttribute("layout", this.model.layout); + this.setAttribute("color", this.model.color); + this.setAttribute("line-style", this.model.type); + let view: TemplateResult; + if (this.model.label?.length) { + view = html` +
+ ${unsafeHTML(this.model.label)} +
+ `; + } else { + view = html`
`; + } + render(view, this); + } +} +env.bind("divider-component", Divider); diff --git a/client/src/framework/components/divider/index.html b/client/src/framework/components/divider/index.html new file mode 100644 index 0000000..135f762 --- /dev/null +++ b/client/src/framework/components/divider/index.html @@ -0,0 +1,31 @@ + + + + + + + + + + + diff --git a/client/src/framework/components/divider/readme.md b/client/src/framework/components/divider/readme.md new file mode 100644 index 0000000..48aac44 --- /dev/null +++ b/client/src/framework/components/divider/readme.md @@ -0,0 +1,27 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| label | string | | +| color | DividerColor | | +| type | DividerType | | +| layout | DividerLayout | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type DividerColor = "primary" | "success" | "warning" | "danger" | "black" | "grey"; +type DividerType = "solid" | "dashed" | "dotted"; +type DividerLayout = "horizontal" | "vertical"; +``` + diff --git a/client/src/framework/components/divider/static.html b/client/src/framework/components/divider/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/form/form.scss b/client/src/framework/components/form/form.scss new file mode 100644 index 0000000..31c327d --- /dev/null +++ b/client/src/framework/components/form/form.scss @@ -0,0 +1,4 @@ +form-component { + display: inline-block; + position: relative; +} diff --git a/client/src/framework/components/form/form.ts b/client/src/framework/components/form/form.ts new file mode 100644 index 0000000..11efb3a --- /dev/null +++ b/client/src/framework/components/form/form.ts @@ -0,0 +1,83 @@ +import env from "~brixi/controllers/env"; +import Component from "~brixi/component"; + +const FORM_INPUT_SELECTOR = "[form-input]"; + +env.css(["form"]); + +export interface IForm {} +export default class Form extends Component { + override connected(): void { + this.render(); + this.setAttribute("role", "form"); + this.addEventListener("reset", this.handleReset); + } + + public start() { + const el = this.querySelector("submit-button"); + if (el) { + // @ts-ignore + el.trigger("START"); + } + } + + public stop() { + const el = this.querySelector('submit-button[state="SUBMITTING"]'); + if (el) { + // @ts-ignore + el.trigger("STOP"); + } + } + + public reset() { + this.querySelectorAll(FORM_INPUT_SELECTOR).forEach((el) => { + // @ts-ignore + el.reset(); + }); + } + + public serialize() { + this.start(); + const data = {}; + this.querySelectorAll(FORM_INPUT_SELECTOR).forEach((el) => { + // @ts-ignore + const name = el.getName(); + if (name == null || name === "") { + console.error("Form input is missing a name attribute.", el); + } else { + // @ts-ignore + data[name] = el.getValue(); + } + }); + return data; + } + + public checkValidity(): boolean { + let allValid = true; + this.querySelectorAll(FORM_INPUT_SELECTOR).forEach((el) => { + // @ts-ignore + if (!el.validate()) { + allValid = false; + } + }); + return allValid; + } + + public fail(errors: { [name: string]: string }) { + const inputs = {}; + this.querySelectorAll(FORM_INPUT_SELECTOR).forEach((el) => { + // @ts-ignore + inputs[el.getName()] = el; + }); + for (const name in errors) { + inputs?.[name]?.setError(errors[name]); + } + this.stop(); + } + + private handleReset: EventListener = (e) => { + e.preventDefault(); + this.reset(); + }; +} +env.bind("form-component", Form); diff --git a/client/src/framework/components/form/index.html b/client/src/framework/components/form/index.html new file mode 100644 index 0000000..51a31ee --- /dev/null +++ b/client/src/framework/components/form/index.html @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/client/src/framework/components/form/readme.md b/client/src/framework/components/form/readme.md new file mode 100644 index 0000000..00f4185 --- /dev/null +++ b/client/src/framework/components/form/readme.md @@ -0,0 +1,52 @@ +```html + + + + + +``` + +### Event Listeners + +The `submit` event will fire when the form is submitted. + +```typescript +document.querySelector('form-component').addEventListener('submit', (event) => { + e.preventDefault(); + const form = e.currentTarget; + if (form.checkValidity()){ + console.log(form.serialize()); + setTimeout(() => { + form.reset(); + form.stop(); + }, 1000); + } +}); +``` + +The `reset` event will fire when the form is reset. + +```typescript +document.querySelector('form-component').addEventListener('reset', (event) => { + // TODO: react to reset +}); +``` + +### Querying Form Inputs + +All form inputs can be queried using the `[form-input]` attribute. + +```typescript +document.body.querySelectorAll("[form-input]").forEach(el => { + const name = el.getName(); + const value = el.getValue(); + const isValid = el.validate(); + el.setError("Set a custom error message"); + el.clearError(); +}); +``` diff --git a/client/src/framework/components/form/static.html b/client/src/framework/components/form/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/generic-list/generic-list.scss b/client/src/framework/components/generic-list/generic-list.scss new file mode 100644 index 0000000..bca2c82 --- /dev/null +++ b/client/src/framework/components/generic-list/generic-list.scss @@ -0,0 +1,21 @@ +generic-list { + display: block; + position: relative; +} +.list { + display: block; + + li { + list-style-position: outside; + margin-left: 1rem; + line-height: 1.618; + font-size: var(--font-sm); + display: list-item; + padding-left: 0.25rem; + list-style-type: disc; + } + + .list { + padding-left: 1rem; + } +} diff --git a/client/src/framework/components/generic-list/generic-list.ts b/client/src/framework/components/generic-list/generic-list.ts new file mode 100644 index 0000000..6bc5870 --- /dev/null +++ b/client/src/framework/components/generic-list/generic-list.ts @@ -0,0 +1,83 @@ +import { html, render } from "lit-html"; +import env from "~brixi/controllers/env"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import Component from "~brixi/component"; +import { parseDataset } from "~brixi/utils/general"; + +env.css(["generic-list"]); + +export type ItemStyle = "disc" | "circle" | "decimal" | "leading-zero" | "square" | "custom"; +export type ListType = "ordered" | "unordered"; +export interface List { + type: ListType; + style?: ItemStyle; + items: Array; + sub?: List; + icon?: string; +} +export interface IGenericList { + list: List; +} +export default class GenericList extends Component { + constructor() { + super(); + this.model = { + list: null, + }; + } + + static get observedAttributes() { + return ["data-list"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + private renderStyleType(style: ItemStyle, custom: string) { + switch (style) { + case "circle": + return "circle"; + case "disc": + return "disc"; + case "decimal": + return "decimal"; + case "leading-zero": + return "decimal-leading-zero"; + case "square": + return "square"; + case "custom": + return `"\\${custom}"`; + default: + return "disc"; + } + } + + private renderItem(item: string, style: ItemStyle = "disc", customIcon = "") { + return html`
  • ${unsafeHTML(decodeURI(item))}
  • `; + } + + private renderList(list: List) { + switch (list?.type) { + case "ordered": + return html` +
      + ${list.items.map((item: string) => this.renderItem(item, list?.style, list?.icon))} ${list?.sub ? this.renderList(list.sub) : ""} +
    + `; + default: + return html` +
      + ${list.items.map((item: string) => this.renderItem(item, list?.style, list?.icon))} ${list?.sub ? this.renderList(list.sub) : ""} +
    + `; + } + } + + override render() { + const view = html` ${this.renderList(this.model.list)} `; + render(view, this); + } +} +env.bind("generic-list", GenericList); diff --git a/client/src/framework/components/generic-list/index.html b/client/src/framework/components/generic-list/index.html new file mode 100644 index 0000000..40c3f8f --- /dev/null +++ b/client/src/framework/components/generic-list/index.html @@ -0,0 +1,9 @@ + + + + + diff --git a/client/src/framework/components/generic-list/readme.md b/client/src/framework/components/generic-list/readme.md new file mode 100644 index 0000000..c061f2f --- /dev/null +++ b/client/src/framework/components/generic-list/readme.md @@ -0,0 +1,39 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| list | List | ✅ | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type ItemStyle = "disc" | "circle" | "decimal" | "leading-zero" | "square" | "custom"; +type ListType = "ordered" | "unordered"; +type List = { + type: ListType; + style?: ItemStyle; + items: Array; + sub?: List; + icon?: string; +}; +``` + +### HTML Content + +You can render HTML content in a list item by using the `encodeURI()` function. [Learn more about URI encoding on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI). + +```javascript +html` + ')}"]}' + > +` +``` diff --git a/client/src/framework/components/generic-list/static.html b/client/src/framework/components/generic-list/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/inputs/color-input/color-input.scss b/client/src/framework/components/inputs/color-input/color-input.scss new file mode 100644 index 0000000..29e2f29 --- /dev/null +++ b/client/src/framework/components/inputs/color-input/color-input.scss @@ -0,0 +1,65 @@ +color-input { + display: inline-flex; + align-items: center; + position: relative; + width: 100%; + height: 36px; + + &[state="DISABLED"] { + opacity: 0.6; + cursor: not-allowed !important; + + @media (prefers-color-scheme: dark) { + opacity: 0.3; + } + } + + input[type="color"] { + opacity: 0; + visibility: hidden; + position: absolute; + top: 0; + left: 0; + + &:disabled { + & + label { + cursor: not-allowed !important; + + span { + color: var(--grey-400) !important; + } + } + } + + &[readonly] { + & + label { + pointer-events: none; + } + } + } + + label { + display: inline-flex; + align-items: center; + flex-flow: row nowrap; + cursor: pointer; + + color-preview { + width: 32px; + height: 32px; + border-radius: 0.25rem; + overflow: hidden; + margin-right: 0.75rem; + box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.3); + } + + span { + color: var(--grey-800); + font-weight: var(--font-medium); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + } +} diff --git a/client/src/framework/components/inputs/color-input/color-input.ts b/client/src/framework/components/inputs/color-input/color-input.ts new file mode 100644 index 0000000..5f08251 --- /dev/null +++ b/client/src/framework/components/inputs/color-input/color-input.ts @@ -0,0 +1,78 @@ +import { UUID } from "@codewithkyle/uuid"; +import { html, render } from "lit-html"; +import env from "~brixi/controllers/env"; +import { IInputBase, InputBase } from "../input-base"; +import "~brixi/utils/strings"; + +env.css(["color-input"]); + +export interface IColorInput extends IInputBase { + value: string; + label: string; + readOnly: boolean; +} +export default class ColorInput extends InputBase { + private inputId: string; + + constructor() { + super(); + this.inputId = UUID(); + this.model = { + value: "000000", + name: "", + label: "", + disabled: false, + readOnly: false, + error: "", + required: false, + }; + } + + static get observedAttributes() { + return ["data-value", "data-name", "data-label", "data-disabled", "data-read-only", "data-required"]; + } + + override validate(): boolean { + return true; + } + + private handleInput = (e: Event) => { + e.stopImmediatePropagation(); + const target = e.currentTarget as HTMLInputElement; + const value = target.value; + this.set({ + value: value, + }); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + name: target.name, + value: value, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + override render() { + this.setAttribute("state", this.state); + const view = html` + + + `; + render(view, this); + } +} +env.bind("color-input", ColorInput); diff --git a/client/src/framework/components/inputs/color-input/index.html b/client/src/framework/components/inputs/color-input/index.html new file mode 100644 index 0000000..609601b --- /dev/null +++ b/client/src/framework/components/inputs/color-input/index.html @@ -0,0 +1,21 @@ + + + + + + diff --git a/client/src/framework/components/inputs/color-input/readme.md b/client/src/framework/components/inputs/color-input/readme.md new file mode 100644 index 0000000..f490418 --- /dev/null +++ b/client/src/framework/components/inputs/color-input/readme.md @@ -0,0 +1,33 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| value | string | | +| label | string | | +| disabled | boolean | | +| readOnly | boolean | | +| required | boolean | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Event Listeners + +The `change` event will fire when the user changes the color. + +```typescript +document.body.querySelector('color-input').addEventListener('change', (e) => { + const { name, value } = e.detail; +}); +``` diff --git a/client/src/framework/components/inputs/color-input/static.html b/client/src/framework/components/inputs/color-input/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/inputs/date-input/date-input.ts b/client/src/framework/components/inputs/date-input/date-input.ts new file mode 100644 index 0000000..0dcbad8 --- /dev/null +++ b/client/src/framework/components/inputs/date-input/date-input.ts @@ -0,0 +1,226 @@ +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import env from "~brixi/controllers/env"; +import flatpickr from "flatpickr"; +import { IInputBase, InputBase } from "../input-base"; +import { UUID } from "@codewithkyle/uuid"; + +env.css(["input", "flatpickr"]); + +export interface IDateInput extends IInputBase { + label: string; + instructions: string; + autocomplete: string; + autocapitalize: "off" | "on"; + icon: string; + placeholder: string; + autofocus: boolean; + value: string; + dateFormat: string; + displayFormat: string; + enableTime: boolean; + minDate: string; + maxDate: string; + mode: "multiple" | "single" | "range"; + disableCalendar: boolean; + timeFormat: "24" | "12"; + prevValue: string | number; +} +export default class DateInput extends InputBase { + private firstRender: boolean; + private inputId: string; + + constructor() { + super(); + this.inputId = UUID(); + this.firstRender = true; + this.stateMachine = { + IDLING: { + ERROR: "ERROR", + DISABLE: "DISABLED", + }, + ERROR: { + RESET: "IDLING", + ERROR: "ERROR", + }, + DISABLED: { + ENABLE: "IDLING", + }, + }; + this.model = { + label: "", + instructions: null, + error: null, + name: "", + required: false, + autocomplete: "off", + autocapitalize: "off", + icon: null, + placeholder: "", + value: null, + disabled: false, + dateFormat: "Z", + displayFormat: "F j, Y", + enableTime: false, + minDate: null, + maxDate: null, + mode: "single", + disableCalendar: false, + timeFormat: "12", + prevValue: null, + autofocus: false, + }; + } + + static get observedAttributes() { + return [ + "data-label", + "data-instructions", + "data-name", + "data-required", + "data-autocomplete", + "data-autocapitalize", + "data-icon", + "data-placeholder", + "data-value", + "data-disabled", + "data-date-format", + "data-display-format", + "data-enable-time", + "data-min-date", + "data-max-date", + "data-mode", + "data-disable-calendar", + "data-time-format", + "data-prev-value", + "data-autofocus", + ]; + } + + private handleInput: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const input = e.currentTarget as HTMLInputElement; + this.set( + { + // @ts-ignore + prevValue: this.model.value?.toString(), + value: input.value, + }, + true + ); + this.validate(); + if (this.model.mode === "range") { + if (this.model.value.toString().search(/\bto\b/i) !== -1 || (this.model.prevValue === this.model.value && this.model.value != null)) { + const values = this.model.value.toString().split(" to "); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + name: this.model.name, + start: values[0].trim(), + end: values[1].trim(), + }, + bubbles: true, + cancelable: true, + }) + ); + } + } else if (this.model.mode === "multiple") { + this.dispatchEvent( + new CustomEvent("change", { + detail: { + name: this.model.name, + values: this.model.value + .toString() + .split(",") + .map((value) => value.trim()), + }, + bubbles: true, + cancelable: true, + }) + ); + } else { + this.dispatchEvent( + new CustomEvent("change", { + detail: { + name: this.model.name, + value: this.model.value.toString(), + }, + bubbles: true, + cancelable: true, + }) + ); + } + }; + + private renderCopy(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.state === "IDLING" && this.model.instructions) { + output = html`

    ${unsafeHTML(this.model.instructions)}

    `; + } else if (this.state === "ERROR" && this.model.error) { + output = html`

    ${this.model.error}

    `; + } + return output; + } + + private renderIcon(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.icon?.length) { + output = html`${unsafeHTML(this.model.icon)}`; + } + return output; + } + + private renderLabel(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.label?.length) { + output = html``; + } + return output; + } + + override render() { + if (this.model.mode === "range" && !this.firstRender) { + return; + } + this.classList.add("input"); + this.setAttribute("state", this.state); + const view = html` + ${this.renderLabel()} ${this.renderCopy()} + + ${this.renderIcon()} + + + `; + render(view, this); + + if (this.state !== "DISABLED") { + const input = this.querySelector("input"); + flatpickr(input, { + dateFormat: this.model.dateFormat, + enableTime: this.model.enableTime, + altFormat: this.model.displayFormat, + altInput: true, + minDate: this.model.minDate, + maxDate: this.model.maxDate, + mode: this.model.mode, + noCalendar: this.model.disableCalendar, + time_24hr: this.model.timeFormat === "24", + }); + } + this.firstRender = false; + } +} +env.bind("date-input-component", DateInput); diff --git a/client/src/framework/components/inputs/date-input/flatpickr.scss b/client/src/framework/components/inputs/date-input/flatpickr.scss new file mode 100644 index 0000000..16fe18b --- /dev/null +++ b/client/src/framework/components/inputs/date-input/flatpickr.scss @@ -0,0 +1,849 @@ +@media (prefers-color-scheme: dark) { + .flatpickr-calendar { + background-color: var(--grey-950) !important; + color: var(--grey-300) !important; + box-shadow: var(--shadow-black-md) !important; + + &::before, + &::after { + border-bottom-color: var(--grey-950) !important; + } + + .flatpickr-months { + .flatpickr-month .flatpickr-current-month { + color: var(--grey-300) !important; + } + + .flatpickr-prev-month, + .flatpickr-next-month { + fill: var(--grey-300) !important; + } + } + + .flatpickr-weekdays { + .flatpickr-weekday { + color: var(--grey-300) !important; + } + } + + .flatpickr-days { + .flatpickr-day { + color: var(--grey-300) !important; + + &.prevMonthDay { + color: var(--grey-500) !important; + } + + &.today { + border-color: var(--grey-800) !important; + } + + &.selected { + background-color: hsl(var(--primary-400-hsl) / 0.05) !important; + border-color: var(--primary-200) !important; + color: var(--primary-300) !important; + } + + &:hover, + &:focus-visible { + background-color: hsl(var(--white-hsl) / 0.05) !important; + color: var(--white) !important; + border-color: var(--grey-800) !important; + } + } + } + } +} + +/* Static flatpickr CSS */ +.flatpickr-calendar { + background: transparent; + opacity: 0; + display: none; + text-align: center; + visibility: hidden; + padding: 0; + -webkit-animation: none; + animation: none; + direction: ltr; + border: 0; + font-size: 14px; + line-height: 24px; + border-radius: 5px; + position: absolute; + width: 307.875px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + -ms-touch-action: manipulation; + touch-action: manipulation; + background: #fff; + -webkit-box-shadow: 1px 0 0 #e6e6e6, -1px 0 0 #e6e6e6, 0 1px 0 #e6e6e6, 0 -1px 0 #e6e6e6, 0 3px 13px rgba(0, 0, 0, 0.08); + box-shadow: 1px 0 0 #e6e6e6, -1px 0 0 #e6e6e6, 0 1px 0 #e6e6e6, 0 -1px 0 #e6e6e6, 0 3px 13px rgba(0, 0, 0, 0.08); +} +.flatpickr-calendar.open, +.flatpickr-calendar.inline { + opacity: 1; + max-height: 640px; + visibility: visible; +} +.flatpickr-calendar.open { + display: inline-block; + z-index: 99999; +} +.flatpickr-calendar.animate.open { + -webkit-animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1); + animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.flatpickr-calendar.inline { + display: block; + position: relative; + top: 2px; +} +.flatpickr-calendar.static { + position: absolute; + top: calc(100% + 2px); +} +.flatpickr-calendar.static.open { + z-index: 999; + display: block; +} +.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n + 1) .flatpickr-day.inRange:nth-child(7n + 7) { + -webkit-box-shadow: none !important; + box-shadow: none !important; +} +.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n + 2) .flatpickr-day.inRange:nth-child(7n + 1) { + -webkit-box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6; + box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6; +} +.flatpickr-calendar .hasWeeks .dayContainer, +.flatpickr-calendar .hasTime .dayContainer { + border-bottom: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.flatpickr-calendar .hasWeeks .dayContainer { + border-left: 0; +} +.flatpickr-calendar.hasTime .flatpickr-time { + height: 40px; + border-top: 1px solid #e6e6e6; +} +.flatpickr-calendar.noCalendar.hasTime .flatpickr-time { + height: auto; +} +.flatpickr-calendar:before, +.flatpickr-calendar:after { + position: absolute; + display: block; + pointer-events: none; + border: solid transparent; + content: ""; + height: 0; + width: 0; + left: 22px; +} +.flatpickr-calendar.rightMost:before, +.flatpickr-calendar.arrowRight:before, +.flatpickr-calendar.rightMost:after, +.flatpickr-calendar.arrowRight:after { + left: auto; + right: 22px; +} +.flatpickr-calendar.arrowCenter:before, +.flatpickr-calendar.arrowCenter:after { + left: 50%; + right: 50%; +} +.flatpickr-calendar:before { + border-width: 5px; + margin: 0 -5px; +} +.flatpickr-calendar:after { + border-width: 4px; + margin: 0 -4px; +} +.flatpickr-calendar.arrowTop:before, +.flatpickr-calendar.arrowTop:after { + bottom: 100%; +} +.flatpickr-calendar.arrowTop:before { + border-bottom-color: #e6e6e6; +} +.flatpickr-calendar.arrowTop:after { + border-bottom-color: #fff; +} +.flatpickr-calendar.arrowBottom:before, +.flatpickr-calendar.arrowBottom:after { + top: 100%; +} +.flatpickr-calendar.arrowBottom:before { + border-top-color: #e6e6e6; +} +.flatpickr-calendar.arrowBottom:after { + border-top-color: #fff; +} +.flatpickr-calendar:focus { + outline: 0; +} +.flatpickr-wrapper { + position: relative; + display: inline-block; +} +.flatpickr-months { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} +.flatpickr-months .flatpickr-month { + background: transparent; + color: rgba(0, 0, 0, 0.9); + fill: rgba(0, 0, 0, 0.9); + height: 34px; + line-height: 1; + text-align: center; + position: relative; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + overflow: hidden; + -webkit-box-flex: 1; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; +} +.flatpickr-months .flatpickr-prev-month, +.flatpickr-months .flatpickr-next-month { + text-decoration: none; + cursor: pointer; + position: absolute; + top: 0; + height: 34px; + padding: 10px; + z-index: 3; + color: rgba(0, 0, 0, 0.9); + fill: rgba(0, 0, 0, 0.9); +} +.flatpickr-months .flatpickr-prev-month.flatpickr-disabled, +.flatpickr-months .flatpickr-next-month.flatpickr-disabled { + display: none; +} +.flatpickr-months .flatpickr-prev-month i, +.flatpickr-months .flatpickr-next-month i { + position: relative; +} +.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month, +.flatpickr-months .flatpickr-next-month.flatpickr-prev-month { + /* + /*rtl:begin:ignore*/ + /* + */ + left: 0; + /* + /*rtl:end:ignore*/ + /* + */ +} +/* + /*rtl:begin:ignore*/ +/* + /*rtl:end:ignore*/ +.flatpickr-months .flatpickr-prev-month.flatpickr-next-month, +.flatpickr-months .flatpickr-next-month.flatpickr-next-month { + /* + /*rtl:begin:ignore*/ + /* + */ + right: 0; + /* + /*rtl:end:ignore*/ + /* + */ +} +/* + /*rtl:begin:ignore*/ +/* + /*rtl:end:ignore*/ +.flatpickr-months .flatpickr-prev-month:hover, +.flatpickr-months .flatpickr-next-month:hover { + color: #959ea9; +} +.flatpickr-months .flatpickr-prev-month:hover svg, +.flatpickr-months .flatpickr-next-month:hover svg { + fill: #f64747; +} +.flatpickr-months .flatpickr-prev-month svg, +.flatpickr-months .flatpickr-next-month svg { + width: 14px; + height: 14px; +} +.flatpickr-months .flatpickr-prev-month svg path, +.flatpickr-months .flatpickr-next-month svg path { + -webkit-transition: fill 0.1s; + transition: fill 0.1s; + fill: inherit; +} +.numInputWrapper { + position: relative; + height: auto; +} +.numInputWrapper input, +.numInputWrapper span { + display: inline-block; +} +.numInputWrapper input { + width: 100%; +} +.numInputWrapper input::-ms-clear { + display: none; +} +.numInputWrapper input::-webkit-outer-spin-button, +.numInputWrapper input::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; +} +.numInputWrapper span { + position: absolute; + right: 0; + width: 14px; + padding: 0 4px 0 2px; + height: 50%; + line-height: 50%; + opacity: 0; + cursor: pointer; + border: 1px solid rgba(57, 57, 57, 0.15); + -webkit-box-sizing: border-box; + box-sizing: border-box; +} +.numInputWrapper span:hover { + background: rgba(0, 0, 0, 0.1); +} +.numInputWrapper span:active { + background: rgba(0, 0, 0, 0.2); +} +.numInputWrapper span:after { + display: block; + content: ""; + position: absolute; +} +.numInputWrapper span.arrowUp { + top: 0; + border-bottom: 0; +} +.numInputWrapper span.arrowUp:after { + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 4px solid rgba(57, 57, 57, 0.6); + top: 26%; +} +.numInputWrapper span.arrowDown { + top: 50%; +} +.numInputWrapper span.arrowDown:after { + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid rgba(57, 57, 57, 0.6); + top: 40%; +} +.numInputWrapper span svg { + width: inherit; + height: auto; +} +.numInputWrapper span svg path { + fill: rgba(0, 0, 0, 0.5); +} +.numInputWrapper:hover { + background: rgba(0, 0, 0, 0.05); +} +.numInputWrapper:hover span { + opacity: 1; +} +.flatpickr-current-month { + font-size: 135%; + line-height: inherit; + font-weight: 300; + color: inherit; + position: absolute; + width: 75%; + left: 12.5%; + padding: 7.48px 0 0 0; + line-height: 1; + height: 34px; + display: inline-block; + text-align: center; + -webkit-transform: translate3d(0px, 0px, 0px); + transform: translate3d(0px, 0px, 0px); +} +.flatpickr-current-month span.cur-month { + font-family: inherit; + font-weight: 700; + color: inherit; + display: inline-block; + margin-left: 0.5ch; + padding: 0; +} +.flatpickr-current-month span.cur-month:hover { + background: rgba(0, 0, 0, 0.05); +} +.flatpickr-current-month .numInputWrapper { + width: 6ch; + width: 7ch\0; + display: inline-block; +} +.flatpickr-current-month .numInputWrapper span.arrowUp:after { + border-bottom-color: rgba(0, 0, 0, 0.9); +} +.flatpickr-current-month .numInputWrapper span.arrowDown:after { + border-top-color: rgba(0, 0, 0, 0.9); +} +.flatpickr-current-month input.cur-year { + background: transparent; + -webkit-box-sizing: border-box; + box-sizing: border-box; + color: inherit; + cursor: text; + padding: 0 0 0 0.5ch; + margin: 0; + display: inline-block; + font-size: inherit; + font-family: inherit; + font-weight: 300; + line-height: inherit; + height: auto; + border: 0; + border-radius: 0; + vertical-align: initial; + -webkit-appearance: textfield; + -moz-appearance: textfield; + appearance: textfield; +} +.flatpickr-current-month input.cur-year:focus { + outline: 0; +} +.flatpickr-current-month input.cur-year[disabled], +.flatpickr-current-month input.cur-year[disabled]:hover { + font-size: 100%; + color: rgba(0, 0, 0, 0.5); + background: transparent; + pointer-events: none; +} +.flatpickr-current-month .flatpickr-monthDropdown-months { + appearance: menulist; + background: transparent; + border: none; + border-radius: 0; + box-sizing: border-box; + color: inherit; + cursor: pointer; + font-size: inherit; + font-family: inherit; + font-weight: 300; + height: auto; + line-height: inherit; + margin: -1px 0 0 0; + outline: none; + padding: 0 0 0 0.5ch; + position: relative; + vertical-align: initial; + -webkit-box-sizing: border-box; + -webkit-appearance: menulist; + -moz-appearance: menulist; + width: auto; +} +.flatpickr-current-month .flatpickr-monthDropdown-months:focus, +.flatpickr-current-month .flatpickr-monthDropdown-months:active { + outline: none; +} +.flatpickr-current-month .flatpickr-monthDropdown-months:hover { + background: rgba(0, 0, 0, 0.05); +} +.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month { + background-color: transparent; + outline: none; + padding: 0; +} +.flatpickr-weekdays { + background: transparent; + text-align: center; + overflow: hidden; + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -webkit-align-items: center; + -ms-flex-align: center; + align-items: center; + height: 28px; +} +.flatpickr-weekdays .flatpickr-weekdaycontainer { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-flex: 1; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; +} +span.flatpickr-weekday { + cursor: default; + font-size: 90%; + background: transparent; + color: rgba(0, 0, 0, 0.54); + line-height: 1; + margin: 0; + text-align: center; + display: block; + -webkit-box-flex: 1; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + font-weight: bolder; +} +.dayContainer, +.flatpickr-weeks { + padding: 1px 0 0 0; +} +.flatpickr-days { + position: relative; + overflow: hidden; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-align: start; + -webkit-align-items: flex-start; + -ms-flex-align: start; + align-items: flex-start; + width: 307.875px; +} +.flatpickr-days:focus { + outline: 0; +} +.dayContainer { + padding: 0; + outline: 0; + text-align: left; + width: 307.875px; + min-width: 307.875px; + max-width: 307.875px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + display: inline-block; + display: -ms-flexbox; + display: -webkit-box; + display: -webkit-flex; + display: flex; + -webkit-flex-wrap: wrap; + flex-wrap: wrap; + -ms-flex-wrap: wrap; + -ms-flex-pack: justify; + -webkit-justify-content: space-around; + justify-content: space-around; + -webkit-transform: translate3d(0px, 0px, 0px); + transform: translate3d(0px, 0px, 0px); + opacity: 1; +} +.dayContainer + .dayContainer { + -webkit-box-shadow: -1px 0 0 #e6e6e6; + box-shadow: -1px 0 0 #e6e6e6; +} +.flatpickr-day { + background: none; + border: 1px solid transparent; + border-radius: 150px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + color: #393939; + cursor: pointer; + font-weight: 400; + width: 14.2857143%; + -webkit-flex-basis: 14.2857143%; + -ms-flex-preferred-size: 14.2857143%; + flex-basis: 14.2857143%; + max-width: 39px; + height: 39px; + line-height: 39px; + margin: 0; + display: inline-block; + position: relative; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + text-align: center; +} +.flatpickr-day.inRange, +.flatpickr-day.prevMonthDay.inRange, +.flatpickr-day.nextMonthDay.inRange, +.flatpickr-day.today.inRange, +.flatpickr-day.prevMonthDay.today.inRange, +.flatpickr-day.nextMonthDay.today.inRange, +.flatpickr-day:hover, +.flatpickr-day.prevMonthDay:hover, +.flatpickr-day.nextMonthDay:hover, +.flatpickr-day:focus, +.flatpickr-day.prevMonthDay:focus, +.flatpickr-day.nextMonthDay:focus { + cursor: pointer; + outline: 0; + background: #e6e6e6; + border-color: #e6e6e6; +} +.flatpickr-day.today { + border-color: #959ea9; +} +.flatpickr-day.today:hover, +.flatpickr-day.today:focus { + border-color: #959ea9; + background: #959ea9; + color: #fff; +} +.flatpickr-day.selected, +.flatpickr-day.startRange, +.flatpickr-day.endRange, +.flatpickr-day.selected.inRange, +.flatpickr-day.startRange.inRange, +.flatpickr-day.endRange.inRange, +.flatpickr-day.selected:focus, +.flatpickr-day.startRange:focus, +.flatpickr-day.endRange:focus, +.flatpickr-day.selected:hover, +.flatpickr-day.startRange:hover, +.flatpickr-day.endRange:hover, +.flatpickr-day.selected.prevMonthDay, +.flatpickr-day.startRange.prevMonthDay, +.flatpickr-day.endRange.prevMonthDay, +.flatpickr-day.selected.nextMonthDay, +.flatpickr-day.startRange.nextMonthDay, +.flatpickr-day.endRange.nextMonthDay { + background: #569ff7; + -webkit-box-shadow: none; + box-shadow: none; + color: #fff; + border-color: #569ff7; +} +.flatpickr-day.selected.startRange, +.flatpickr-day.startRange.startRange, +.flatpickr-day.endRange.startRange { + border-radius: 50px 0 0 50px; +} +.flatpickr-day.selected.endRange, +.flatpickr-day.startRange.endRange, +.flatpickr-day.endRange.endRange { + border-radius: 0 50px 50px 0; +} +.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n + 1)), +.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n + 1)), +.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n + 1)) { + -webkit-box-shadow: -10px 0 0 #569ff7; + box-shadow: -10px 0 0 #569ff7; +} +.flatpickr-day.selected.startRange.endRange, +.flatpickr-day.startRange.startRange.endRange, +.flatpickr-day.endRange.startRange.endRange { + border-radius: 50px; +} +.flatpickr-day.inRange { + border-radius: 0; + -webkit-box-shadow: -5px 0 0 #e6e6e6, 5px 0 0 #e6e6e6; + box-shadow: -5px 0 0 #e6e6e6, 5px 0 0 #e6e6e6; +} +.flatpickr-day.flatpickr-disabled, +.flatpickr-day.flatpickr-disabled:hover, +.flatpickr-day.prevMonthDay, +.flatpickr-day.nextMonthDay, +.flatpickr-day.notAllowed, +.flatpickr-day.notAllowed.prevMonthDay, +.flatpickr-day.notAllowed.nextMonthDay { + color: rgba(57, 57, 57, 0.3); + background: transparent; + border-color: transparent; + cursor: default; +} +.flatpickr-day.flatpickr-disabled, +.flatpickr-day.flatpickr-disabled:hover { + cursor: not-allowed; + color: rgba(57, 57, 57, 0.1); +} +.flatpickr-day.week.selected { + border-radius: 0; + -webkit-box-shadow: -5px 0 0 #569ff7, 5px 0 0 #569ff7; + box-shadow: -5px 0 0 #569ff7, 5px 0 0 #569ff7; +} +.flatpickr-day.hidden { + visibility: hidden; +} +.rangeMode .flatpickr-day { + margin-top: 1px; +} +.flatpickr-weekwrapper { + float: left; +} +.flatpickr-weekwrapper .flatpickr-weeks { + padding: 0 12px; + -webkit-box-shadow: 1px 0 0 #e6e6e6; + box-shadow: 1px 0 0 #e6e6e6; +} +.flatpickr-weekwrapper .flatpickr-weekday { + float: none; + width: 100%; + line-height: 28px; +} +.flatpickr-weekwrapper span.flatpickr-day, +.flatpickr-weekwrapper span.flatpickr-day:hover { + display: block; + width: 100%; + max-width: none; + color: rgba(57, 57, 57, 0.3); + background: transparent; + cursor: default; + border: none; +} +.flatpickr-innerContainer { + display: block; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-sizing: border-box; + box-sizing: border-box; + overflow: hidden; +} +.flatpickr-rContainer { + display: inline-block; + padding: 0; + -webkit-box-sizing: border-box; + box-sizing: border-box; +} +.flatpickr-time { + text-align: center; + outline: 0; + display: block; + height: 0; + line-height: 40px; + max-height: 40px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + overflow: hidden; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} +.flatpickr-time:after { + content: ""; + display: table; + clear: both; +} +.flatpickr-time .numInputWrapper { + -webkit-box-flex: 1; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + width: 40%; + height: 40px; + float: left; +} +.flatpickr-time .numInputWrapper span.arrowUp:after { + border-bottom-color: #393939; +} +.flatpickr-time .numInputWrapper span.arrowDown:after { + border-top-color: #393939; +} +.flatpickr-time.hasSeconds .numInputWrapper { + width: 26%; +} +.flatpickr-time.time24hr .numInputWrapper { + width: 49%; +} +.flatpickr-time input { + background: transparent; + -webkit-box-shadow: none; + box-shadow: none; + border: 0; + border-radius: 0; + text-align: center; + margin: 0; + padding: 0; + height: inherit; + line-height: inherit; + color: #393939; + font-size: 14px; + position: relative; + -webkit-box-sizing: border-box; + box-sizing: border-box; + -webkit-appearance: textfield; + -moz-appearance: textfield; + appearance: textfield; +} +.flatpickr-time input.flatpickr-hour { + font-weight: bold; +} +.flatpickr-time input.flatpickr-minute, +.flatpickr-time input.flatpickr-second { + font-weight: 400; +} +.flatpickr-time input:focus { + outline: 0; + border: 0; +} +.flatpickr-time .flatpickr-time-separator, +.flatpickr-time .flatpickr-am-pm { + height: inherit; + float: left; + line-height: inherit; + color: #393939; + font-weight: bold; + width: 2%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-align-self: center; + -ms-flex-item-align: center; + align-self: center; +} +.flatpickr-time .flatpickr-am-pm { + outline: 0; + width: 18%; + cursor: pointer; + text-align: center; + font-weight: 400; +} +.flatpickr-time input:hover, +.flatpickr-time .flatpickr-am-pm:hover, +.flatpickr-time input:focus, +.flatpickr-time .flatpickr-am-pm:focus { + background: #eee; +} +.flatpickr-input[readonly] { + cursor: pointer; +} +@-webkit-keyframes fpFadeInDown { + from { + opacity: 0; + -webkit-transform: translate3d(0, -20px, 0); + transform: translate3d(0, -20px, 0); + } + to { + opacity: 1; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } +} +@keyframes fpFadeInDown { + from { + opacity: 0; + -webkit-transform: translate3d(0, -20px, 0); + transform: translate3d(0, -20px, 0); + } + to { + opacity: 1; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } +} diff --git a/client/src/framework/components/inputs/date-input/index.html b/client/src/framework/components/inputs/date-input/index.html new file mode 100644 index 0000000..d4ece24 --- /dev/null +++ b/client/src/framework/components/inputs/date-input/index.html @@ -0,0 +1,21 @@ + + + + + diff --git a/client/src/framework/components/inputs/date-input/readme.md b/client/src/framework/components/inputs/date-input/readme.md new file mode 100644 index 0000000..63e0c95 --- /dev/null +++ b/client/src/framework/components/inputs/date-input/readme.md @@ -0,0 +1,45 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| label | string | | +| instructions | string | | +| autocomplete | string | | +| autocapitalize | "on" or "off" | | +| icon | string | | +| placeholder | string | | +| autofocus | boolean | | +| value | string | | +| dateFormat | string | | +| displayFormat | string | | +| enableTime | boolean | | +| minDate | string | | +| maxDate | string | | +| mode | "multiple" or "single" or "range" | | +| disableCalendar | boolean | | +| timeFormat | "24" or "12" | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +Not sure what `autocomplete` values you can use? Learn about the [autocomplete attribute on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete). + +### Event Listeners + +The `change` event will fire when the user picks dates using the date picker. + +```typescript +document.body.querySelector('date-input-component').addEventListener('change', (e) => { + const { name, value } = e.detail; // mode: signle + const { name, values } = e.detail; // mode: multiple + const { name, start, end } = e.detail; // mode: range +}); +``` diff --git a/client/src/framework/components/inputs/date-input/static.html b/client/src/framework/components/inputs/date-input/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/inputs/email-input/email-input.ts b/client/src/framework/components/inputs/email-input/email-input.ts new file mode 100644 index 0000000..dfd239d --- /dev/null +++ b/client/src/framework/components/inputs/email-input/email-input.ts @@ -0,0 +1,183 @@ +import { UUID } from "@codewithkyle/uuid"; +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import env from "~brixi/controllers/env"; +import { InputBase } from "../input-base"; +import { IInput } from "../input/input"; + +env.css("input"); + +export default class EmailInput extends InputBase { + private inputId: string; + + constructor() { + super(); + this.inputId = UUID(); + this.model = { + label: "", + instructions: null, + error: null, + name: "", + required: false, + autocomplete: "off", + autocapitalize: "off", + icon: null, + placeholder: "", + value: null, + disabled: false, + maxlength: 9999, + minlength: 0, + datalist: [], + autofocus: false, + }; + } + + static get observedAttributes() { + return [ + "data-label", + "data-instructions", + "data-name", + "data-required", + "data-autocomplete", + "data-autocapitalize", + "data-icon", + "data-placeholder", + "data-value", + "data-maxlength", + "data-minlength", + "data-disabled", + "data-datalist", + "data-autofocus", + ]; + } + + override validate(): boolean { + let isValid = true; + const EmailTest = new RegExp(/[^@ \t\r\n]+@[^@ \t\r\n]+\.[^@ \t\r\n]+/gm); + if (this.model.required && !this.model.value?.length) { + isValid = false; + this.setError("This field is required."); + } else if (this.model.required || (!this.model.required && this.model.value?.length)) { + if (this.model.value.length && !EmailTest.test(this.model.value)) { + isValid = false; + this.setError(`Invalid email format.`); + } else if (this.model.minlength > this.model.value.length) { + isValid = false; + this.setError(`This input requires a least ${this.model.minlength} characters.`); + } else if (this.model.maxlength < this.model.value.length) { + isValid = false; + this.setError(`This input requires a least ${this.model.minlength} characters.`); + } + } + if (isValid) { + this.clearError(); + } + return isValid; + } + + private handleInput: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const input = e.currentTarget as HTMLInputElement; + this.set( + { + value: input.value, + }, + true + ); + this.clearError(); + this.dispatchEvent( + new CustomEvent("input", { + detail: { + value: input.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleBlur: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + this.validate(); + this.dispatchEvent( + new CustomEvent("blur", { + detail: { + value: this.model.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleFocus: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + this.dispatchEvent( + new CustomEvent("focus", { + detail: { + value: this.model.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private renderCopy(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.state === "IDLING" && this.model.instructions) { + output = html`

    ${unsafeHTML(this.model.instructions)}

    `; + } else if (this.state === "ERROR" && this.model.error) { + output = html`

    ${this.model.error}

    `; + } + return output; + } + + private renderIcon(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.icon?.length) { + output = html`${unsafeHTML(this.model.icon)}`; + } + return output; + } + + private renderLabel(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.label?.length) { + output = html``; + } + return output; + } + + override render() { + this.setAttribute("state", this.state); + this.classList.add("input"); + const view = html` + ${this.renderLabel()} ${this.renderCopy()} + + ${this.renderIcon()} + + + `; + render(view, this); + } +} +env.bind("email-input-component", EmailInput); diff --git a/client/src/framework/components/inputs/email-input/index.html b/client/src/framework/components/inputs/email-input/index.html new file mode 100644 index 0000000..f597401 --- /dev/null +++ b/client/src/framework/components/inputs/email-input/index.html @@ -0,0 +1,19 @@ + + + diff --git a/client/src/framework/components/inputs/email-input/readme.md b/client/src/framework/components/inputs/email-input/readme.md new file mode 100644 index 0000000..52fbdb4 --- /dev/null +++ b/client/src/framework/components/inputs/email-input/readme.md @@ -0,0 +1,58 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| label | string | | +| instructions | string | | +| icon | string | | +| required | boolean | | +| autocomplete | string | | +| autocapitalize | "on" or "off" | | +| placeholder | string | | +| value | string | | +| maxlength | number | | +| minlength | number | | +| disabled | boolean | | +| readOnly | boolean | | +| datalist | string[] | | +| autofocus | boolean | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +Not sure what `autocomplete` values you can use? Learn about the [autocomplete attribute on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete). + +### Event Listeners + +The `input` event will fire while the user types. + +```typescript +document.body.querySelector('email-input-component').addEventListener('input', (e) => { + const { name, value } = e.detail; +}); +``` + +The `focus` event will fire when the user focuses the input. + +```typescript +document.body.querySelector('email-input-component').addEventListener('focus', (e) => { + const { name, value } = e.detail; +}); +``` + +The `blur` event will fire when the user blurs the input. + +```typescript +document.body.querySelector('email-input-component').addEventListener('blur', (e) => { + const { name, value } = e.detail; +}); +``` diff --git a/client/src/framework/components/inputs/email-input/static.html b/client/src/framework/components/inputs/email-input/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/inputs/input-base.ts b/client/src/framework/components/inputs/input-base.ts new file mode 100644 index 0000000..063cba2 --- /dev/null +++ b/client/src/framework/components/inputs/input-base.ts @@ -0,0 +1,96 @@ +import { parseDataset } from "~brixi/utils/general"; +import soundscape from "~brixi/controllers/soundscape"; +import Component from "~brixi/component"; + +export interface IInputBase { + name: string; + error: string; + required: boolean; + value: any; + disabled: boolean; +} +export class InputBase extends Component { + constructor() { + super(); + this.stateMachine = { + IDLING: { + ERROR: "ERROR", + DISABLE: "DISABLED", + }, + ERROR: { + RESET: "IDLING", + ERROR: "ERROR", + }, + DISABLED: { + ENABLE: "IDLING", + }, + }; + // @ts-ignore + this.model = { + error: null, + name: "", + required: false, + value: "", + disabled: false, + }; + this.setAttribute("form-input", ""); + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + // @ts-ignore + this.state = settings?.disabled ? "DISABLED" : "IDLING"; + this.set(settings); + } + + public reset(): void { + this.set({ + // @ts-ignore + value: null, + }); + const input = this.querySelector("input") as HTMLInputElement; + if (input) { + input.value = ""; + } + } + + public clearError(): void { + if (this.state === "ERROR") { + this.trigger("RESET"); + } + } + + public setError(error: string): void { + if (error?.length) { + this.set({ + // @ts-ignore + error: error, + }); + this.trigger("ERROR"); + soundscape.play("error"); + } + } + + public validate(): boolean { + let isValid = true; + // @ts-ignore + if (this.model.required && !this.model.value) { + isValid = false; + this.setError("This field is required."); + } + if (isValid) { + this.clearError(); + } + return isValid; + } + + public getName(): string { + // @ts-ignore + return this.model?.name ?? ""; + } + + public getValue(): any { + // @ts-ignore + return this.model?.value ?? null; + } +} diff --git a/client/src/framework/components/inputs/input/index.html b/client/src/framework/components/inputs/input/index.html new file mode 100644 index 0000000..a67de6d --- /dev/null +++ b/client/src/framework/components/inputs/input/index.html @@ -0,0 +1,35 @@ + + + + + + + diff --git a/client/src/framework/components/inputs/input/input.scss b/client/src/framework/components/inputs/input/input.scss new file mode 100644 index 0000000..54ebb07 --- /dev/null +++ b/client/src/framework/components/inputs/input/input.scss @@ -0,0 +1,270 @@ +.input { + display: inline-block; + width: 100%; + position: relative; + + &[readonly] { + input-container { + input { + padding-right: 2.5rem; + } + } + } + + &[state="DISABLED"] { + cursor: not-allowed !important; + opacity: 0.6; + + @media (prefers-color-scheme: dark) { + opacity: 0.3; + } + + label, + p { + color: var(--grey-400) !important; + + @media (prefers-color-scheme: dark) { + color: var(--grey-300) !important; + } + } + input-container { + background-color: var(--grey-50) !important; + border-color: var(--grey-200) !important; + box-shadow: none !important; + + @media (prefers-color-scheme: dark) { + background-color: transparent !important; + border-color: var(--grey-700) !important; + } + + input { + cursor: not-allowed !important; + background-color: var(--grey-50) !important; + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--white-hsl) / 0.05) !important; + } + } + + i { + @media (prefers-color-scheme: dark) { + border-color: var(--grey-700) !important; + background-color: hsl(var(--white-hsl) / 0.05) !important; + color: var(--grey-400) !important; + } + } + } + } + + &[state="ERROR"] { + p { + color: var(--danger-700) !important; + } + input-container { + border-color: var(--danger-400) !important; + + i { + background-color: var(--danger-50) !important; + border-color: var(--danger-400) !important; + color: var(--danger-400) !important; + } + } + + @media (prefers-color-scheme: dark) { + p { + color: var(--danger-400) !important; + } + input-container { + &:focus-within { + box-shadow: 0 0 0 5px hsl(var(--danger-400-hsl) / 0.1) !important; + } + + i { + background-color: hsl(var(--danger-300-hsl) / 0.05) !important; + border-color: var(--danger-400) !important; + color: var(--danger-400) !important; + } + input { + background-color: hsl(var(--danger-300-hsl) / 0.05) !important; + } + } + } + } + + label { + display: block; + width: 100%; + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--grey-800); + margin-bottom: 0.5rem; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + + input { + height: 36px; + display: block; + width: 100%; + flex: 1; + text-overflow: ellipsis; + overflow: hidden; + line-height: 36px; + padding: 0 0.5rem; + color: var(--grey-800); + transition: all 80ms var(--ease-in-out); + + &::placeholder { + color: var(--grey-400); + } + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + background-color: hsl(var(--white-hsl) / 0.05); + + &:focus:not(:read-only) { + background-color: hsl(var(--white-hsl) / 0); + } + + &::placeholder { + color: var(--grey-500); + } + } + } + + p { + display: block; + margin-bottom: 0.5rem; + font-size: var(--font-xs); + color: var(--grey-500); + line-height: 1.375; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + + input-container { + overflow: hidden; + display: flex; + flex-flow: row nowrap; + width: 100%; + border-radius: 0.375rem; + border: var(--input-border); + background-color: var(--white); + transition: all 80ms var(--ease-in-out); + box-shadow: 0 1px 1px hsl(var(--black-hsl) / 0.1); + outline-offset: 0; + + &:focus-within { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + transition: outline-offset 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + border-color: var(--primary-400); + outline: none; + box-shadow: 0 0 0 5px hsl(var(--primary-400-hsl) / 0.1); + + i { + border-color: var(--primary-400); + color: var(--primary-400); + } + } + } + + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-shadow: var(--bevel); + pointer-events: none; + z-index: 5; + } + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-700); + box-shadow: none; + background-color: transparent; + + &::after { + display: none; + } + } + } + + .eye { + pointer-events: all; + background-color: transparent; + border-right: none; + cursor: pointer; + display: inline-flex; + justify-content: center; + align-items: center; + width: 36px; + height: 36px; + color: var(--grey-400); + z-index: 2; + transition: all 80ms var(--ease-in-out); + position: absolute; + bottom: 0; + right: 0; + + &:hover { + color: var(--grey-700); + } + + &:active { + color: var(--grey-800); + } + + @media (prefers-color-scheme: dark) { + color: var(--grey-400); + + &:hover { + color: var(--grey-200); + } + + &:active { + color: var(--white); + } + } + + svg { + width: 18px; + height: 18px; + } + } + + i { + display: inline-flex; + justify-content: center; + align-items: center; + width: 36px; + height: 36px; + pointer-events: none; + color: var(--grey-400); + background-color: var(--grey-50); + border-right: var(--input-border); + z-index: 2; + transition: all 80ms var(--ease-in-out); + border-radius: 0.375rem 0 0 0.375rem; + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-700); + background-color: hsl(var(--white-hsl) / 0.05); + } + + svg { + width: 18px; + height: 18px; + } + } +} diff --git a/client/src/framework/components/inputs/input/input.ts b/client/src/framework/components/inputs/input/input.ts new file mode 100644 index 0000000..62988bc --- /dev/null +++ b/client/src/framework/components/inputs/input/input.ts @@ -0,0 +1,256 @@ +import { UUID } from "@codewithkyle/uuid"; +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import env from "~brixi/controllers/env"; +import { InputBase, IInputBase } from "../input-base"; +import alerts from "~brixi/controllers/alerts"; + +env.css(["input", "button", "toast"]); + +export interface IInput extends IInputBase { + label: string; + instructions: string; + autocomplete: string; + autocapitalize: "off" | "on"; + icon: string; + placeholder: string; + maxlength: number; + minlength: number; + readOnly?: boolean; + datalist: string[]; + autofocus: boolean; + value: string; +} +export default class Input extends InputBase { + private inputId: string; + + constructor() { + super(); + this.inputId = UUID(); + this.model = { + label: "", + instructions: null, + error: null, + name: "", + required: false, + autocomplete: "off", + autocapitalize: "off", + icon: null, + placeholder: "", + value: null, + maxlength: 9999, + minlength: 0, + disabled: false, + readOnly: false, + datalist: [], + autofocus: false, + }; + } + + static get observedAttributes() { + return [ + "data-label", + "data-instructions", + "data-name", + "data-required", + "data-autocomplete", + "data-autocapitalize", + "data-icon", + "data-placeholder", + "data-value", + "data-maxlength", + "data-minlength", + "data-disabled", + "data-read-only", + "data-datalist", + "data-autofocus", + ]; + } + + override validate(): boolean { + let isValid = true; + if (this.model.required && !this.model.value?.length) { + isValid = false; + this.setError("This field is required."); + } else if (this.model.required || (!this.model.required && this.model.value?.length)) { + if (this.model.minlength > this.model.value?.length) { + isValid = false; + this.setError(`This input requires a least ${this.model.minlength} characters.`); + } else if (this.model.maxlength < this.model.value?.length) { + isValid = false; + this.setError(`This input requires a least ${this.model.minlength} characters.`); + } + } + if (isValid) { + this.clearError(); + } + return isValid; + } + + private noopEvent: EventListener = (e) => { + e.stopImmediatePropagation(); + } + + private handleInput: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const input = e.currentTarget as HTMLInputElement; + this.set( + { + value: input.value, + }, + true + ); + this.clearError(); + this.dispatchEvent( + new CustomEvent("input", { + detail: { + value: input.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleBlur: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + this.validate(); + this.dispatchEvent( + new CustomEvent("blur", { + detail: { + value: this.model.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleFocus: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + this.dispatchEvent( + new CustomEvent("focus", { + detail: { + value: this.model.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleCopyClick: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + window.navigator.clipboard.writeText(this.model.value).then(() => { + alerts.toast("Copied to clipboard"); + }); + }; + + private renderCopy(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.state === "IDLING" && this.model.instructions) { + output = html`

    ${unsafeHTML(this.model.instructions)}

    `; + } else if (this.state === "ERROR" && this.model.error) { + output = html`

    ${this.model.error}

    `; + } + return output; + } + + private renderIcon(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.icon?.length) { + output = html`${unsafeHTML(this.model.icon)}`; + } + return output; + } + + private renderReadOnlyIcon(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.readOnly) { + output = html` + + `; + } + return output; + } + + private renderLabel(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.label?.length) { + output = html``; + } + return output; + } + + private renderDatalist(): string | TemplateResult { + let out: string | TemplateResult = ""; + if (this.model.datalist.length) { + out = html` + + ${this.model.datalist.map((item) => { + return html` `; + })} + + `; + } + return out; + } + + render() { + this.setAttribute("state", this.state); + this.classList.add("input"); + if (this.model.readOnly) { + this.setAttribute("readonly", `${this.model.readOnly}`); + } + const view = html` + ${this.renderLabel()} ${this.renderCopy()} + + ${this.renderIcon()} + + ${this.renderReadOnlyIcon()} + + ${this.renderDatalist()} + `; + render(view, this); + } +} +env.bind("input-component", Input); diff --git a/client/src/framework/components/inputs/input/readme.md b/client/src/framework/components/inputs/input/readme.md new file mode 100644 index 0000000..072dd6f --- /dev/null +++ b/client/src/framework/components/inputs/input/readme.md @@ -0,0 +1,61 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| label | string | | +| instructions | string | | +| icon | string | | +| required | boolean | | +| autocomplete | string | | +| autocapitalize | "on" or "off" | | +| placeholder | string | | +| value | string | | +| maxlength | number | | +| minlength | number | | +| disabled | boolean | | +| readOnly | boolean | | +| datalist | string[] | | +| autofocus | boolean | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +Not sure what `autocomplete` values you can use? Learn about the [autocomplete attribute on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete). + +### Event Listeners + +The `input` event will fire while the user types. + +```typescript +document.body.querySelector('input-component').addEventListener('input', (e) => { + const { name, value } = e.detail; +}); +``` + +The `focus` event will fire when the user focuses the input. + +```typescript +document.body.querySelector('input-component').addEventListener('focus', (e) => { + const { name, value } = e.detail; +}); +``` + +The `blur` event will fire when the user blurs the input. + +```typescript +document.body.querySelector('input-component').addEventListener('blur', (e) => { + const { name, value } = e.detail; +}); +``` diff --git a/client/src/framework/components/inputs/input/static.html b/client/src/framework/components/inputs/input/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/inputs/number-input/index.html b/client/src/framework/components/inputs/number-input/index.html new file mode 100644 index 0000000..3435cbb --- /dev/null +++ b/client/src/framework/components/inputs/number-input/index.html @@ -0,0 +1,23 @@ + + + diff --git a/client/src/framework/components/inputs/number-input/number-input.ts b/client/src/framework/components/inputs/number-input/number-input.ts new file mode 100644 index 0000000..67cf036 --- /dev/null +++ b/client/src/framework/components/inputs/number-input/number-input.ts @@ -0,0 +1,181 @@ +import { UUID } from "@codewithkyle/uuid"; +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import env from "~brixi/controllers/env"; +import { InputBase, IInputBase } from "../input-base"; + +env.css("input"); + +interface INumberInput extends IInputBase { + label: string; + instructions: string; + icon: string; + placeholder: string; + autofocus: boolean; + value: number | null; + min: number; + max: number; + step: number; +} +export default class NumberInput extends InputBase { + private inputId: string; + + constructor() { + super(); + this.inputId = UUID(); + this.model = { + label: "", + instructions: null, + error: null, + name: "", + required: false, + icon: null, + placeholder: "", + value: null, + min: 0, + max: 9999, + step: 1, + disabled: false, + autofocus: false, + }; + } + + static get observedAttributes() { + return [ + "data-label", + "data-instructions", + "data-icon", + "data-placeholder", + "data-value", + "data-min", + "data-max", + "data-step", + "data-disabled", + "data-autofocus", + "data-name", + "data-required", + ]; + } + + override validate(): boolean { + let isValid = true; + if (this.model.required && this.model.value == null) { + isValid = false; + this.setError("This field is required."); + } + if (this.model.value !== null) { + if (this.model.value < this.model.min) { + isValid = false; + this.setError(`Minimum allowed number is ${this.model.min}.`); + } else if (this.model.value > this.model.max) { + isValid = false; + this.setError(`Maximum allowed number is ${this.model.max}.`); + } + } + if (isValid) { + this.clearError(); + } + return isValid; + } + + private handleInput: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const input = e.currentTarget as HTMLInputElement; + const value = input.value.replace(/[^\d\.\-]/g, "").trim(); + this.set({ + value: value?.length ? parseFloat(value) : null, + }); + this.clearError(); + this.dispatchEvent( + new CustomEvent("input", { + detail: { + value: this.model.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleBlur: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + this.validate(); + this.dispatchEvent( + new CustomEvent("blur", { + detail: { + value: this.model.value, + name: this.model.name, + }, + }) + ); + }; + + private handleFocus: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + this.dispatchEvent( + new CustomEvent("focus", { + detail: { + value: this.model.value, + name: this.model.name, + }, + }) + ); + }; + + private renderCopy(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.state === "IDLING" && this.model.instructions) { + output = html`

    ${unsafeHTML(this.model.instructions)}

    `; + } else if (this.state === "ERROR" && this.model.error) { + output = html`

    ${this.model.error}

    `; + } + return output; + } + + private renderIcon(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.icon?.length) { + output = html`${unsafeHTML(this.model.icon)}`; + } + return output; + } + + private renderLabel(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.label?.length) { + output = html``; + } + return output; + } + + override render() { + this.setAttribute("state", this.state); + this.classList.add("input"); + const view = html` + ${this.renderLabel()} ${this.renderCopy()} + + ${this.renderIcon()} + + + `; + render(view, this); + } +} +env.bind("number-input-component", NumberInput); diff --git a/client/src/framework/components/inputs/number-input/readme.md b/client/src/framework/components/inputs/number-input/readme.md new file mode 100644 index 0000000..484f376 --- /dev/null +++ b/client/src/framework/components/inputs/number-input/readme.md @@ -0,0 +1,57 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| label | string | | +| instructions | string | | +| icon | string | | +| required | boolean | | +| placeholder | string | | +| value | number | | +| min | number | | +| max | number | | +| step | number | | +| disabled | boolean | | +| autofocus | boolean | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Event Listeners + +The `input` event will fire while the user types. + +```typescript +document.body.querySelector('number-input-component').addEventListener('input', (e) => { + const { name, value } = e.detail; +}); +``` + +The `focus` event will fire when the user focuses the input. + +```typescript +document.body.querySelector('number-input-component').addEventListener('focus', (e) => { + const { name, value } = e.detail; +}); +``` + +The `blur` event will fire when the user blurs the input. + +```typescript +document.body.querySelector('number-input-component').addEventListener('blur', (e) => { + const { name, value } = e.detail; +}); +``` diff --git a/client/src/framework/components/inputs/number-input/static.html b/client/src/framework/components/inputs/number-input/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/inputs/password-input/index.html b/client/src/framework/components/inputs/password-input/index.html new file mode 100644 index 0000000..5624f05 --- /dev/null +++ b/client/src/framework/components/inputs/password-input/index.html @@ -0,0 +1,20 @@ + + + diff --git a/client/src/framework/components/inputs/password-input/password-input.ts b/client/src/framework/components/inputs/password-input/password-input.ts new file mode 100644 index 0000000..a8c0b13 --- /dev/null +++ b/client/src/framework/components/inputs/password-input/password-input.ts @@ -0,0 +1,226 @@ +import { UUID } from "@codewithkyle/uuid"; +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import env from "~brixi/controllers/env"; +import { InputBase, IInputBase } from "../input-base"; + +env.css("input"); + +interface IPasswordInput extends IInputBase { + label: string; + instructions: string; + autocomplete: string; + icon: string; + placeholder: string; + maxlength: number; + minlength: number; + autofocus: boolean; + value: string; + type: "text" | "password"; +} +export default class PasswordInput extends InputBase { + private inputId: string; + + constructor() { + super(); + this.inputId = UUID(); + this.model = { + label: "", + instructions: null, + error: null, + name: "", + required: false, + autocomplete: "off", + icon: null, + placeholder: "", + value: null, + disabled: false, + maxlength: 9999, + minlength: 0, + type: "password", + autofocus: false, + }; + } + + static get observedAttributes() { + return [ + "data-label", + "data-instructions", + "data-name", + "data-required", + "data-autocomplete", + "data-icon", + "data-placeholder", + "data-value", + "data-disabled", + "data-maxlength", + "data-minlength", + "data-autofocus", + ]; + } + + override validate(): boolean { + let isValid = true; + if (this.model.required && !this.model.value?.length) { + isValid = false; + this.setError("This field is required."); + } + if (this.model.required || (!this.model.required && this.model.value?.length)) { + if (this.model.minlength > this.model.value.length) { + isValid = false; + this.setError(`This input requires a least ${this.model.minlength} characters.`); + } else if (this.model.maxlength < this.model.value.length) { + isValid = false; + this.setError(`This input requires a least ${this.model.minlength} characters.`); + } + } + if (isValid) { + this.clearError(); + } + return isValid; + } + + private toggleVisibility: EventListener = () => { + switch (this.model.type) { + case "password": + this.set({ + // @ts-ignore + type: "text", + }); + break; + case "text": + this.set({ + // @ts-ignore + type: "password", + }); + break; + } + }; + + private handleInput: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const input = e.currentTarget as HTMLInputElement; + this.set({ + value: input.value, + }); + this.clearError(); + this.dispatchEvent( + new CustomEvent("input", { + detail: { + value: input.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleBlur: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + this.validate(); + this.dispatchEvent( + new CustomEvent("blur", { + detail: { + value: this.model.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleFocus: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + this.dispatchEvent( + new CustomEvent("focus", { + detail: { + value: this.model.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private renderCopy(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.state === "IDLING" && this.model.instructions) { + output = html`

    ${unsafeHTML(this.model.instructions)}

    `; + } else if (this.state === "ERROR" && this.model.error) { + output = html`

    ${this.model.error}

    `; + } + return output; + } + + private renderIcon(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.icon?.length) { + output = html`${unsafeHTML(this.model.icon)}`; + } + return output; + } + + private renderLabel(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.label?.length) { + output = html``; + } + return output; + } + + private renderEyeIcon(): TemplateResult { + switch (this.model.type) { + case "password": + return html` + + + `; + case "text": + return html` + + `; + } + } + + override render() { + this.setAttribute("state", this.state); + this.classList.add("input"); + const view = html` + ${this.renderLabel()} ${this.renderCopy()} + + ${this.renderIcon()} + + + + `; + render(view, this); + } +} +env.bind("password-input-component", PasswordInput); diff --git a/client/src/framework/components/inputs/password-input/readme.md b/client/src/framework/components/inputs/password-input/readme.md new file mode 100644 index 0000000..a4f1c50 --- /dev/null +++ b/client/src/framework/components/inputs/password-input/readme.md @@ -0,0 +1,53 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| label | string | | +| instructions | string | | +| icon | string | | +| required | boolean | | +| placeholder | string | | +| value | number | | +| disabled | boolean | | +| autofocus | boolean | | +| maxlength | number | | +| minlength | number | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Event Listeners + +The `input` event will fire while the user types. + +```typescript +document.body.querySelector('password-input-component').addEventListener('input', (e) => { + const { name, value } = e.detail; +}); +``` + +The `focus` event will fire when the user focuses the input. + +```typescript +document.body.querySelector('password-input-component').addEventListener('focus', (e) => { + const { name, value } = e.detail; +}); +``` + +The `blur` event will fire when the user blurs the input. + +```typescript +document.body.querySelector('password-input-component').addEventListener('blur', (e) => { + const { name, value } = e.detail; +}); +``` diff --git a/client/src/framework/components/inputs/password-input/static.html b/client/src/framework/components/inputs/password-input/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/inputs/phone-input/index.html b/client/src/framework/components/inputs/phone-input/index.html new file mode 100644 index 0000000..0ed0ddd --- /dev/null +++ b/client/src/framework/components/inputs/phone-input/index.html @@ -0,0 +1,17 @@ + + + diff --git a/client/src/framework/components/inputs/phone-input/phone-input.ts b/client/src/framework/components/inputs/phone-input/phone-input.ts new file mode 100644 index 0000000..aec243a --- /dev/null +++ b/client/src/framework/components/inputs/phone-input/phone-input.ts @@ -0,0 +1,192 @@ +import { UUID } from "@codewithkyle/uuid"; +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import env from "~brixi/controllers/env"; +import { InputBase, IInputBase } from "../input-base"; + +env.css("input"); + +interface IPhoneInput extends IInputBase { + label: string; + instructions: string; + autocomplete: string; + icon: string; + placeholder: string; + datalist: string[]; + autofocus: boolean; + value: string; +} +export default class PhoneInput extends InputBase { + private inputId: string; + + constructor() { + super(); + this.inputId = UUID(); + this.model = { + label: "", + instructions: null, + error: null, + name: "", + required: false, + autocomplete: "off", + icon: null, + placeholder: "", + value: null, + disabled: false, + datalist: [], + autofocus: false, + }; + } + + static get observedAttributes() { + return ["data-label", "data-instructions", "data-name", "data-required", "data-icon", "data-placeholder", "data-value", "data-disabled", "data-datalist", "data-autofocus"]; + } + + override validate(): boolean { + let isValid = true; + const PhoneNumberCheck = new RegExp(/^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/gim); + if (this.model.required && !this.model.value?.length) { + isValid = false; + this.setError("This field is required."); + } + if ((!this.model.required && this.model.value?.length) || this.model.required) { + if (!PhoneNumberCheck.test(`${this.model.value}`)) { + isValid = false; + this.setError(`Invalid phone number.`); + } + } + if (isValid) { + this.clearError(); + } + return isValid; + } + + /** + * Formats phone number string (US) + * @see https://stackoverflow.com/a/8358141 + * @license https://creativecommons.org/licenses/by-sa/4.0/ + */ + private formatPhoneNumber(phoneNumber: string): string { + var cleaned = ("" + phoneNumber).replace(/\D/g, ""); + var match = cleaned.match(/^(1|)?(\d{3})(\d{3})(\d{4})$/); + if (match) { + var intlCode = match[1] ? "+1 " : ""; + return [intlCode, "(", match[2], ") ", match[3], "-", match[4]].join(""); + } + return phoneNumber; + } + + private handleBlur: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const input = e.currentTarget as HTMLInputElement; + const formattedValue = this.formatPhoneNumber(input.value); + this.set({ + value: formattedValue, + }); + this.validate(); + this.dispatchEvent( + new CustomEvent("blur", { + detail: { + value: formattedValue, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleFocus: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const value = this.model.value?.toString()?.replace(/[\-\+\s\(\)]/g, "") ?? null; + this.set({ + value: value, + }); + this.dispatchEvent( + new CustomEvent("focus", { + detail: { + value: value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleInput: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const input = e.currentTarget as HTMLInputElement; + this.set( + { + value: input.value, + }, + true + ); + this.clearError(); + this.dispatchEvent( + new CustomEvent("input", { + detail: { + value: input.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private renderCopy(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.state === "IDLING" && this.model.instructions) { + output = html`

    ${unsafeHTML(this.model.instructions)}

    `; + } else if (this.state === "ERROR" && this.model.error) { + output = html`

    ${this.model.error}

    `; + } + return output; + } + + private renderIcon(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.icon?.length) { + output = html`${unsafeHTML(this.model.icon)}`; + } + return output; + } + + private renderLabel(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.label?.length) { + output = html``; + } + return output; + } + + override render() { + this.setAttribute("state", this.state); + this.classList.add("input"); + const view = html` + ${this.renderLabel()} ${this.renderCopy()} + + ${this.renderIcon()} + + + `; + render(view, this); + } +} +env.bind("phone-input-component", PhoneInput); diff --git a/client/src/framework/components/inputs/phone-input/readme.md b/client/src/framework/components/inputs/phone-input/readme.md new file mode 100644 index 0000000..2e79ae9 --- /dev/null +++ b/client/src/framework/components/inputs/phone-input/readme.md @@ -0,0 +1,52 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| label | string | | +| instructions | string | | +| icon | string | | +| required | boolean | | +| autocomplete | string | | +| placeholder | string | | +| value | string | | +| disabled | boolean | | +| datalist | string[] | | +| autofocus | boolean | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +Not sure what `autocomplete` values you can use? Learn about the [autocomplete attribute on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete). + +### Event Listeners + +The `input` event will fire while the user types. + +```typescript +document.body.querySelector('phone-input-component').addEventListener('input', (e) => { + const { name, value } = e.detail; +}); +``` + +The `focus` event will fire when the user focuses the input. + +```typescript +document.body.querySelector('phone-input-component').addEventListener('focus', (e) => { + const { name, value } = e.detail; +}); +``` + +The `blur` event will fire when the user blurs the input. + +```typescript +document.body.querySelector('phone-input-component').addEventListener('blur', (e) => { + const { name, value } = e.detail; +}); +``` diff --git a/client/src/framework/components/inputs/phone-input/static.html b/client/src/framework/components/inputs/phone-input/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/inputs/range-slider/index.html b/client/src/framework/components/inputs/range-slider/index.html new file mode 100644 index 0000000..8de17da --- /dev/null +++ b/client/src/framework/components/inputs/range-slider/index.html @@ -0,0 +1,44 @@ + + + + + + + diff --git a/client/src/framework/components/inputs/range-slider/range-slider.scss b/client/src/framework/components/inputs/range-slider/range-slider.scss new file mode 100644 index 0000000..6978c4d --- /dev/null +++ b/client/src/framework/components/inputs/range-slider/range-slider.scss @@ -0,0 +1,232 @@ +range-slider { + display: inline-block; + width: 100%; + position: relative; + + &[state="DISABLED"] { + opacity: 0.6; + cursor: not-allowed !important; + + @media (prefers-color-scheme: dark) { + opacity: 0.3; + } + } + + label { + display: block; + width: 100%; + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--grey-800); + margin-bottom: 0.5rem; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + + p { + display: block; + margin-bottom: 0.5rem; + font-size: var(--font-xs); + color: var(--grey-700); + line-height: 1.375; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + + input[type="number"] { + height: 36px; + min-width: 36px; + margin-left: 1rem; + display: block; + text-overflow: ellipsis; + overflow: hidden; + line-height: 36px; + text-align: center; + color: var(--grey-800); + transition: all 80ms var(--ease-in-out); + border: var(--input-border); + background-color: var(--white); + border-radius: 0.375rem; + box-shadow: var(--bevel); + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + appearance: none; + margin: 0; + } + + &:focus { + border-color: var(--primary-500); + box-shadow: 0 0 0 5px hsl(var(--primary-500-hsl) / 0.05); + } + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + background-color: hsl(var(--white-hsl) / 0.05); + border: 1px solid var(--grey-700); + + &:focus { + border-color: var(--primary-400); + background-color: hsl(var(--white-hsl) / 0); + box-shadow: 0 0 0 5px hsl(var(--primary-400-hsl) / 0.1); + } + } + } + + input[type="range"] { + display: block; + width: 100%; + appearance: none; + inline-size: 100%; + outline-offset: 5px; + //background: linear-gradient(to right, transparent var(--track-fill), var(--grey-100) 0%), var(--primary-400) fixed; + background: transparent; + border-radius: 3px; + height: 6px; + flex: 1; + + &:hover { + &::-webkit-slider-thumb { + box-shadow: 0 0 0 5px hsl(var(--primary-500-hsl) / 0.05); + background: var(--primary-400) fixed; + + @media (prefers-color-scheme: dark) { + box-shadow: 0 0 0 5px hsl(var(--primary-400-hsl) / 0.1); + } + } + &::-moz-slider-thumb { + box-shadow: 0 0 0 5px hsl(var(--primary-500-hsl) / 0.05); + background: var(--primary-400) fixed; + + @media (prefers-color-scheme: dark) { + box-shadow: 0 0 0 5px hsl(var(--primary-400-hsl) / 0.1); + } + } + } + + &:active { + &::-webkit-slider-thumb { + box-shadow: 0 0 0 8px hsl(var(--primary-500-hsl) / 0.05); + background: var(--primary-600) fixed; + + @media (prefers-color-scheme: dark) { + box-shadow: 0 0 0 8px hsl(var(--primary-400-hsl) / 0.1); + background: var(--primary-300) fixed; + } + } + &::-moz-slider-thumb { + box-shadow: 0 0 0 8px hsl(var(--primary-500-hsl) / 0.05); + background: var(--primary-600) fixed; + + @media (prefers-color-scheme: dark) { + box-shadow: 0 0 0 8px hsl(var(--primary-400-hsl) / 0.1); + background: var(--primary-300) fixed; + } + } + } + + &:focus-visible { + &::-webkit-slider-runnable-track { + box-shadow: var(--focus-ring); + } + &::-moz-slider-runnable-track { + box-shadow: var(--focus-ring); + } + &::-webkit-slider-thumb { + box-shadow: var(--focus-ring); + } + &::-moz-slider-thumb { + box-shadow: var(--focus-ring); + } + } + + &::-webkit-slider-runnable-track { + appearance: none; + block-size: 6px; + border-radius: 3px; + background: linear-gradient(to right, transparent var(--track-fill), var(--grey-100) 0%), var(--primary-400) fixed; + + @media (prefers-color-scheme: dark) { + background: linear-gradient(to right, transparent var(--track-fill), hsl(var(--grey-950-hsl) / 0.6) 0%), var(--primary-400) fixed; + } + } + &::-moz-slider-runnable-track { + appearance: none; + block-size: 6px; + border-radius: 3px; + background: linear-gradient(to right, transparent var(--track-fill), var(--grey-100) 0%), var(--primary-400) fixed; + @media (prefers-color-scheme: dark) { + background: linear-gradient(to right, transparent var(--track-fill), hsl(var(--grey-950-hsl) / 0.6) 0%), var(--primary-400) fixed; + } + } + + &::-webkit-slider-thumb { + appearance: none; + cursor: ew-resize; + border: 3px solid var(--white); + block-size: 24px; + inline-size: 24px; + margin-top: -9px; + border-radius: 50%; + background: var(--primary-500) fixed; + transition: all 150ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + border: 3px solid var(--grey-700); + background: var(--grey-800) fixed; + } + } + &::-moz-slider-thumb { + appearance: none; + cursor: ew-resize; + border: 3px solid var(--white); + block-size: 24px; + inline-size: 24px; + margin-top: -9px; + border-radius: 50%; + background: var(--primary-500) fixed; + transition: all 150ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + border: 3px solid var(--grey-700); + background: var(--grey-800) fixed; + } + } + } + + input-container { + display: flex; + flex-flow: row nowrap; + align-items: center; + width: 100%; + background-color: var(--white); + + @media (prefers-color-scheme: dark) { + background-color: transparent; + } + } + + button { + display: inline-flex; + justify-content: center; + align-items: center; + width: 36px; + height: 36px; + margin-right: 0.5rem; + color: var(--grey-400); + z-index: 2; + transition: all 80ms var(--ease-in-out); + cursor: pointer; + + svg { + width: 24px; + height: 24px; + } + } +} diff --git a/client/src/framework/components/inputs/range-slider/range-slider.ts b/client/src/framework/components/inputs/range-slider/range-slider.ts new file mode 100644 index 0000000..6ffc662 --- /dev/null +++ b/client/src/framework/components/inputs/range-slider/range-slider.ts @@ -0,0 +1,272 @@ +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import env from "~brixi/controllers/env"; +import { InputBase, IInputBase } from "../input-base"; +import { calcPercent } from "~brixi/utils/numpy"; +import { UUID } from "@codewithkyle/uuid"; + +env.css(["range-slider"]); + +export interface IRangeSlider extends IInputBase { + label: string; + instructions: string; + icon: string; + readOnly: boolean; + autofocus: boolean; + min: number; + max: number; + step: number; + manual: boolean; + value: number; + minIcon: string; + maxIcon: string; +} +export default class RangeSlider extends InputBase { + private fillPercentage: number; + private inputId: string; + + constructor() { + super(); + this.inputId = UUID(); + this.model = { + manual: false, + label: "", + name: "", + instructions: "", + readOnly: false, + required: false, + disabled: false, + error: "", + icon: "", + minIcon: null, + maxIcon: null, + value: null, + min: 0, + max: 9999, + step: 1, + autofocus: false, + }; + } + + static get observedAttributes() { + return [ + "data-label", + "data-name", + "data-instructions", + "data-icon", + "data-read-only", + "data-required", + "data-manual", + "data-disabled", + "data-value", + "data-min", + "data-max", + "data-step", + "data-autofocus", + "data-min-icon", + "data-max-icon", + ]; + } + + private handleChange: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + }; + + private handleInput: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const input = e.currentTarget as HTMLInputElement; + let newValue = parseInt(input.value); + if (isNaN(newValue)) { + newValue = this.model.min; + } + if (newValue < this.model.min) { + newValue = this.model.max; + } else if (newValue > this.model.max) { + newValue = this.model.max; + } + this.renderFill(newValue); + this.set({ + value: newValue, + }); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + value: newValue, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleBlur: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const input = e.currentTarget as HTMLInputElement; + let newValue = parseInt(input.value); + if (isNaN(newValue)) { + newValue = this.model.min; + } + if (newValue < this.model.min) { + newValue = this.model.max; + } else if (newValue > this.model.max) { + newValue = this.model.max; + } + this.renderFill(newValue); + this.set({ + value: newValue, + }); + this.dispatchEvent( + new CustomEvent("blur", { + detail: { + value: newValue, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleFocus: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + this.dispatchEvent( + new CustomEvent("focus", { + detail: { + value: this.model.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleIconClick: EventListener = () => { + let newValue = 0; + if (this.model.value === this.model.min) { + newValue = this.model.max; + } else { + newValue = this.model.min; + } + this.renderFill(newValue); + this.set({ + value: newValue, + }); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + value: newValue, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + override reset() { + this.set({ + value: this.model.min, + }); + } + + override validate(): boolean { + return true; + } + + private renderCopy(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.state === "IDLING" && this.model.instructions) { + output = html`

    ${unsafeHTML(this.model.instructions)}

    `; + } else if (this.state === "ERROR" && this.model.error) { + output = html`

    ${this.model.error}

    `; + } + return output; + } + + private renderLabel(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.label?.length) { + output = html``; + } + return output; + } + + private renderManualInput(): string | TemplateResult { + let out: string | TemplateResult = ""; + if (this.model.manual) { + out = html` + + `; + } + return out; + } + + private renderFill(newValue: number): void { + this.fillPercentage = calcPercent(newValue, this.model.max); + this.style.setProperty("--track-fill", `${this.fillPercentage}%`); + } + + private renderIcon(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.minIcon != null && this.model.value === this.model.min) { + if (this.model.minIcon?.length) { + output = html``; + } + } else if (this.model.maxIcon != null && this.model.value === this.model.max) { + if (this.model.maxIcon?.length) { + output = html``; + } + } else { + if (this.model.icon?.length) { + output = html``; + } + } + return output; + } + + override render() { + this.classList.add("input"); + this.setAttribute("state", this.state); + this.renderFill(this.model.value); + const view = html` + ${this.renderLabel()} ${this.renderCopy()} + + ${this.renderIcon()} + + ${this.renderManualInput()} + + `; + render(view, this); + } +} +env.bind("range-slider", RangeSlider); diff --git a/client/src/framework/components/inputs/range-slider/readme.md b/client/src/framework/components/inputs/range-slider/readme.md new file mode 100644 index 0000000..393a4fd --- /dev/null +++ b/client/src/framework/components/inputs/range-slider/readme.md @@ -0,0 +1,60 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| manual | boolean | | +| label | string | | +| instructions | string | | +| icon | string | | +| minIcon | string | | +| maxIcon | string | | +| required | boolean | | +| value | number | | +| min | number | | +| max | number | | +| step | number | | +| disabled | boolean | | +| readOnly | boolean | | +| autofocus | boolean | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Event Listeners + +The `change` event will fire while the user types. + +```typescript +document.body.querySelector('range-slider').addEventListener('change', (e) => { + const { name, value } = e.detail; +}); +``` + +The `focus` event will fire when the user focuses the input. + +```typescript +document.body.querySelector('range-slider').addEventListener('focus', (e) => { + const { name, value } = e.detail; +}); +``` + +The `blur` event will fire when the user blurs the input. + +```typescript +document.body.querySelector('range-slider').addEventListener('blur', (e) => { + const { name, value } = e.detail; +}); +``` diff --git a/client/src/framework/components/inputs/range-slider/static.html b/client/src/framework/components/inputs/range-slider/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/lightswitch/index.html b/client/src/framework/components/lightswitch/index.html new file mode 100644 index 0000000..a786e65 --- /dev/null +++ b/client/src/framework/components/lightswitch/index.html @@ -0,0 +1,59 @@ + + + + + + + + + + + diff --git a/client/src/framework/components/lightswitch/lightswitch.scss b/client/src/framework/components/lightswitch/lightswitch.scss new file mode 100644 index 0000000..13afc21 --- /dev/null +++ b/client/src/framework/components/lightswitch/lightswitch.scss @@ -0,0 +1,230 @@ +lightswitch-component { + display: inline-block; + width: 100%; + position: relative; + + &[state="DISABLED"] { + cursor: not-allowed !important; + opacity: 0.6; + + label { + light-switch { + cursor: not-allowed !important; + } + } + + @media (prefers-color-scheme: dark) { + opacity: 0.3; + } + } + + &[color="primary"] { + span:first-of-type { + color: var(--primary-600); + + svg { + color: var(--primary-500); + } + } + + @media (prefers-color-scheme: dark) { + span:first-of-type { + color: var(--primary-400); + + svg { + color: var(--primary-400); + } + } + } + } + + &[color="danger"] { + span:first-of-type { + color: var(--danger-600); + + svg { + color: var(--danger-500); + } + } + + @media (prefers-color-scheme: dark) { + span:first-of-type { + color: var(--danger-400); + + svg { + color: var(--danger-400); + } + } + } + } + + &[color="warning"] { + span:first-of-type { + color: var(--warning-600); + + svg { + color: var(--warning-500); + } + } + + @media (prefers-color-scheme: dark) { + span:first-of-type { + color: var(--warning-400); + + svg { + color: var(--warning-400); + } + } + } + } + + &.is-active { + label { + outline-offset: 0 !important; + } + } + + input { + position: absolute; + opacity: 0; + top: 0; + left: 0; + user-select: none; + visibility: hidden; + + &:disabled { + & + label { + cursor: not-allowed !important; + } + } + + &:checked { + & + label { + light-switch { + background-color: var(--grey-50); + border-color: var(--grey-300); + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--grey-950-hsl) / 0.6); + border-color: hsl(var(--grey-950-hsl) / 0.6); + } + } + } + } + } + + label { + display: flex; + align-items: center; + flex-flow: row nowrap; + width: 100%; + + light-switch { + display: inline-block; + white-space: nowrap; + cursor: pointer; + border-radius: 12px; + background-color: var(--grey-50); + border: 1px solid var(--grey-300); + transition: all 80ms var(--ease-in-out); + height: 24px; + overflow: hidden; + outline-offset: 0; + min-width: 48px; + user-select: none; + + &:hover { + background-color: var(--grey-100); + } + + &:active { + background-color: var(--grey-200); + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + transition: outline-offset 80ms var(--ease-in-out); + } + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--grey-950-hsl) / 0.6); + border-color: hsl(var(--grey-950-hsl) / 0.6); + + &:hover { + background-color: hsl(var(--grey-950-hsl) / 0.6); + } + + &:active { + background-color: hsl(var(--grey-950-hsl) / 0.6); + } + } + + i { + display: inline-block; + width: 16px; + height: 16px; + border-radius: 50%; + vertical-align: top; + background-color: var(--white); + border: 1px solid var(--grey-200); + box-shadow: var(--bevel); + transition: all 150ms var(--ease-in-out); + transform: translate(0, 3px); + + @media (prefers-color-scheme: dark) { + background-color: var(--grey-700); + border-color: var(--grey-700); + } + } + + span { + transition: all 150ms var(--ease-in-out); + color: var(--grey-700); + display: inline-flex; + vertical-align: top; + align-items: center; + flex-flow: row nowrap; + justify-content: center; + font-weight: var(--font-medium); + font-size: var(--font-sm); + line-height: 24px; + height: 24px; + min-width: 18px; + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + + &:first-of-type { + color: var(--success-600); + + svg { + color: var(--success-500); + } + + @media (prefers-color-scheme: dark) { + color: var(--success-400); + + svg { + color: var(--success-400); + } + } + } + + svg { + display: inline-block; + width: 16px; + height: 16px; + vertical-align: middle; + color: var(--grey-600); + transform: translateY(-1px); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + } + } + } +} diff --git a/client/src/framework/components/lightswitch/lightswitch.ts b/client/src/framework/components/lightswitch/lightswitch.ts new file mode 100644 index 0000000..624d886 --- /dev/null +++ b/client/src/framework/components/lightswitch/lightswitch.ts @@ -0,0 +1,212 @@ +import { UUID } from "@codewithkyle/uuid"; +import { html, render } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import Component from "~brixi/component"; +import env from "~brixi/controllers/env"; +import soundscape from "~brixi/controllers/soundscape"; +import { parseDataset } from "~brixi/utils/general"; + +env.css("lightswitch"); + +export type LightswitchColor = "primary" | "success" | "warning" | "danger"; +export interface ILightswitch { + label: string; + instructions: string; + enabledLabel: string; + disabledLabel: string; + enabled: boolean; + name: string; + disabled: boolean; + color: LightswitchColor; + value: string | number; + required: boolean; +} +export default class Lightswitch extends Component { + private inputId: string; + + constructor() { + super(); + this.inputId = UUID(); + this.model = { + name: "", + label: "", + instructions: "", + enabledLabel: null, + disabledLabel: null, + enabled: false, + disabled: false, + color: "success", + value: null, + required: false, + }; + } + + static get observedAttributes() { + return [ + "data-label", + "data-instructions", + "data-enabled-label", + "data-disabled-label", + "data-enabled", + "data-disabled", + "data-color", + "data-value", + "data-required", + "data-name", + ]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.state = settings.disabled ? "DISABLED" : "IDLING"; + this.set(settings); + } + + public getName(): string { + return this.model.name; + } + + public getValue(): string | number | null { + if (this.model.enabled) { + return this.model.value; + } else { + return null; + } + } + + public reset(): void { + this.set({ + enabled: false, + }); + } + + public clearError(): void { + if (this.state === "ERROR") { + this.trigger("RESET"); + } + } + + public setError(error: string): void { + if (error?.length) { + this.set({ + // @ts-ignore + error: error, + }); + this.trigger("ERROR"); + soundscape.play("error"); + } + } + + public validate(): boolean { + let isValid = true; + if (this.model.required && !this.model.enabled) { + isValid = false; + this.setError("This field is required"); + } + return isValid; + } + + private handleChange: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const target = e.currentTarget as HTMLInputElement; + this.set({ + enabled: target.checked, + }); + if (target.checked) { + soundscape.play("activate"); + } else { + soundscape.play("deactivate"); + } + this.dispatchEvent( + new CustomEvent("change", { + detail: { + name: this.model.name, + value: this.model.value, + enabled: this.model.enabled, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleKeyup: EventListener = (e: KeyboardEvent) => { + if (e.key === " ") { + const input = this.querySelector("input") as HTMLInputElement; + input.checked = !input.checked; + this.classList.remove("is-active"); + this.set({ enabled: input.checked }); + if (input.checked) { + soundscape.play("activate"); + } else { + soundscape.play("deactivate"); + } + this.dispatchEvent( + new CustomEvent("change", { + detail: { + name: this.model.name, + value: this.model.value, + enabled: this.model.enabled, + }, + bubbles: true, + cancelable: true, + }) + ); + } + }; + + private handleKeydown: EventListener = (e: KeyboardEvent) => { + if (e.key === " ") { + this.classList.add("is-active"); + } + }; + + private resize() { + const label: HTMLElement = this.querySelector("light-switch"); + const span1: HTMLElement = label.querySelector("span:first-of-type"); + const span2: HTMLElement = label.querySelector("span:last-of-type"); + const i = this.querySelector("i"); + if (this.model.enabled) { + label.style.width = `${span1.scrollWidth + 32}px`; + span1.style.transform = `translateX(6px)`; + span2.style.transform = `translateX(6px)`; + i.style.transform = `translate(6px, 3px)`; + } else { + label.style.width = `${span2.scrollWidth + 32}px`; + span1.style.transform = `translateX(-${span1.scrollWidth}px)`; + span2.style.transform = `translateX(-${span1.scrollWidth}px)`; + i.style.transform = `translate(-${span1.scrollWidth}px, 3px)`; + } + } + + override render() { + this.setAttribute("state", this.state); + this.setAttribute("color", this.model.color); + this.setAttribute("form-input", ""); + const view = html` + + + `; + render(view, this); + this.resize(); + } +} +env.bind("lightswitch-component", Lightswitch); diff --git a/client/src/framework/components/lightswitch/readme.md b/client/src/framework/components/lightswitch/readme.md new file mode 100644 index 0000000..c599ac1 --- /dev/null +++ b/client/src/framework/components/lightswitch/readme.md @@ -0,0 +1,44 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| label | string | | +| instructions | string | | +| required | boolean | | +| value | number | | +| disabled | boolean | | +| enabled | boolean | | +| color | Color | | +| enabledLabel | string | | +| disabledLabel | string | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type Color = "primary" | "success" | "warning" | "danger"; +``` + +### Event Listeners + +The `change` event will fire when the user toggles the lightswitch. + +```typescript +document.body.querySelector('lightswitch-component').addEventListener('change', (e) => { + const { name, value, checked } = e.detail; +}); +``` + diff --git a/client/src/framework/components/lightswitch/static.html b/client/src/framework/components/lightswitch/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/link/index.html b/client/src/framework/components/link/index.html new file mode 100644 index 0000000..5b16322 --- /dev/null +++ b/client/src/framework/components/link/index.html @@ -0,0 +1 @@ +Example link diff --git a/client/src/framework/components/link/link.scss b/client/src/framework/components/link/link.scss new file mode 100644 index 0000000..259a492 --- /dev/null +++ b/client/src/framework/components/link/link.scss @@ -0,0 +1,34 @@ +.link { + text-decoration: none; + color: var(--primary-700); + outline-offset: 0; + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + transition: outline-offset 80ms var(--ease-in-out); + } + + &:hover { + color: var(--primary-600); + text-decoration: underline; + } + + &:active { + color: var(--primary-800); + text-decoration: underline; + outline-offset: 0; + } + + @media (prefers-color-scheme: dark) { + color: var(--primary-300); + + &:hover { + color: var(--primary-400); + } + + &:active { + color: var(--primary-200); + } + } +} diff --git a/client/src/framework/components/link/readme.md b/client/src/framework/components/link/readme.md new file mode 100644 index 0000000..5228844 --- /dev/null +++ b/client/src/framework/components/link/readme.md @@ -0,0 +1,6 @@ +You can style any text element as a link by adding the `link` class. + +```html +Example link +``` + diff --git a/client/src/framework/components/link/static.html b/client/src/framework/components/link/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/modals/index.html b/client/src/framework/components/modals/index.html new file mode 100644 index 0000000..fb1b001 --- /dev/null +++ b/client/src/framework/components/modals/index.html @@ -0,0 +1,104 @@ +
    + + + + + + + +
    + + diff --git a/client/src/framework/components/modals/modals.scss b/client/src/framework/components/modals/modals.scss new file mode 100644 index 0000000..5a79846 --- /dev/null +++ b/client/src/framework/components/modals/modals.scss @@ -0,0 +1,71 @@ +modal-component { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 3000; + + .backdrop { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: hsl(var(--grey-700-hsl) / 0.6); + backdrop-filter: blur(8px); + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--grey-950-hsl) / 0.6); + } + } + + .modal { + position: relative; + border: 1px solid var(--grey-300); + box-shadow: var(--shadow-black-lg); + background-color: var(--white); + border-radius: 1rem; + max-width: calc(100vw - 2rem); + max-height: calc(100vh - 2rem); + overflow-y: auto; + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-800); + box-shadow: var(--shadow-black-lg); + background-color: var(--grey-900); + } + } + + &.static-content { + .modal { + h2 { + font-size: var(--font-lg); + font-weight: var(--font-medium); + color: var(--grey-800); + line-height: 1.375; + margin-bottom: 1rem; + display: block; + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + + p { + color: var(--grey-700); + font-size: var(--font-sm); + line-height: 1.618; + display: block; + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + } + } +} diff --git a/client/src/framework/components/modals/readme.md b/client/src/framework/components/modals/readme.md new file mode 100644 index 0000000..547b3ec --- /dev/null +++ b/client/src/framework/components/modals/readme.md @@ -0,0 +1,104 @@ +You can get started with using the built in modals by importing the static `modals` class. + +```typescript +import modals from "~brixi/controllers/modals"; +``` + +After importing you can call one of the following modal types: + +### Dangerous Modals + +```typescript +modals.dangerous({ + title: "Delete Account", + message: "Are you sure you want to delete your account? All of your data will be permanently removed. This action cannot be undone.", + confirm: "Delete Account", + callbacks: { + confirm: ()=>{ + console.log("Account deleted"); + }, + cancel: ()=>{ + console.log("Account not deleted"); + }, + }, +}); +``` + +### Passive Modals + +```typescript +modals.passive({ + title: "Unable to connect your account", + message: ` + Your changes were saved, but we could not connect your account due to a technical issue on our end. Please try reconnecting again. If the issue keeps happening, contact Customer Support. + `, + actions: [ + { + label: "Cancel", + callback: ()=>{ + console.log("Canceling"); + }, + }, + { + label: "Try Again", + callback: ()=>{ + console.log("Trying again"); + }, + }, + ], +}); +``` + +### Confirm Modals + +```typescript +modals.confirm({ + title: "Submit Order", + message: "Once your order is submitted production will begin automatically. This action cannot be undone.", + callbacks: { + confirm: ()=>{ + console.log("Order submitted"); + }, + cancel: ()=>{ + console.log("Order not submitted"); + }, + }, +}); +``` + +### Form Modals + +```typescript +modals.form({ + title: "New Project", + view: html` + + `, + callbacks: { + submit: (data, form, modal)=>{ + console.log(data, form, modal); + modal.remove(); + }, + cancel: ()=>{ + console.log("Project not created"); + }, + }, +}); +``` + +### Raw Modals + +```typescript +const modal = modals.raw({ + view: html` +
    + +
    + `, + width: 768, +}); +``` diff --git a/client/src/framework/components/modals/static.html b/client/src/framework/components/modals/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/multi-select/index.html b/client/src/framework/components/multi-select/index.html new file mode 100644 index 0000000..dc23cba --- /dev/null +++ b/client/src/framework/components/multi-select/index.html @@ -0,0 +1,48 @@ + + + + + diff --git a/client/src/framework/components/multi-select/multi-select.scss b/client/src/framework/components/multi-select/multi-select.scss new file mode 100644 index 0000000..92b361f --- /dev/null +++ b/client/src/framework/components/multi-select/multi-select.scss @@ -0,0 +1,366 @@ +multi-select-component { + display: inline-block; + width: 100%; + position: relative; + cursor: pointer; + + &[state="DISABLED"] { + cursor: not-allowed !important; + opacity: 0.6; + + label, + p { + color: var(--grey-400) !important; + } + select-container { + background-color: var(--grey-50) !important; + border-color: var(--grey-200) !important; + box-shadow: none !important; + + &::after { + box-shadow: none !important; + } + + select { + cursor: not-allowed !important; + background-color: var(--grey-50) !important; + } + } + + @media (prefers-color-scheme: dark) { + opacity: 0.3; + + label, + p { + color: var(--grey-300) !important; + } + select-container { + background-color: hsl(var(--white-hsl) / 0.05); + border-color: var(--grey-700) !important; + } + } + } + + &[state="ERROR"] { + p { + color: var(--danger-700) !important; + } + + multiselect-container { + border-color: var(--danger-400) !important; + + .icon { + background-color: var(--danger-50) !important; + border-color: var(--danger-400) !important; + color: var(--danger-400) !important; + } + } + + @media (prefers-color-scheme: dark) { + p { + color: var(--danger-400) !important; + } + multiselect-container { + border-color: var(--danger-400) !important; + .icon { + background-color: hsl(var(--danger-300-hsl) / 0.05) !important; + border-color: var(--danger-400) !important; + color: var(--danger-400) !important; + } + .select { + background-color: hsl(var(--danger-300-hsl) / 0.05); + } + .selector { + color: var(--danger-400) !important; + } + } + + &:focus-within, + &:focus { + multiselect-container { + box-shadow: 0 0 0 5px hsl(var(--danger-400-hsl) / 0.1) !important; + + .select { + background-color: hsl(var(--white-hsl) / 0); + } + } + } + } + } + + &:focus-within:not([state="DISABLED"]), + &:focus:not([state="DISABLED"]) { + multiselect-options { + opacity: 1; + visibility: visible; + pointer-events: all; + } + + multiselect-container { + outline: var(--focus-ring); + outline-offset: 5px; + transition: outline-offset 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + border-color: var(--primary-400); + outline: none; + box-shadow: 0 0 0 5px hsl(var(--primary-400-hsl) / 0.1); + + .icon { + border-color: var(--primary-400); + color: var(--primary-400); + } + + .select { + background-color: hsl(var(--white-hsl) / 0); + } + + .selector { + color: var(--primary-400); + } + } + } + } + + & > label { + display: block; + width: 100%; + font-size: var(--font-sm); + font-weight: 500; + color: var(--grey-700); + margin-bottom: 0.5rem; + text-align: left; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + + .select { + height: 36px; + display: block; + width: 100%; + flex: 1; + text-overflow: ellipsis; + overflow: hidden; + line-height: 36px; + padding: 0 calc(1rem + 24px) 0 0.5rem; + color: var(--grey-800); + transition: all 80ms var(--ease-in-out); + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border: none; + outline: none; + box-shadow: none; + + &::placeholder { + color: var(--grey-400); + } + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + background-color: hsl(var(--white-hsl) / 0.05); + + &:focus { + background-color: hsl(var(--white-hsl) / 0); + } + + &::placeholder { + color: var(--grey-500); + } + } + } + + p { + display: block; + margin-bottom: 0.5rem; + font-size: var(--font-xs); + color: var(--grey-500); + line-height: 1.375; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + + multiselect-container { + display: flex; + flex-flow: row nowrap; + width: 100%; + border-radius: 5px; + overflow: hidden; + border: var(--input-border); + background-color: var(--white); + box-shadow: var(--bevel); + //transition: all 80ms var(--ease-in-out); + outline-offset: 0; + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-700); + box-shadow: none; + background-color: transparent; + } + } + + .selector { + width: 24px; + height: 24px; + display: inline-flex; + justify-content: center; + align-items: center; + color: var(--grey-400); + pointer-events: none; + position: absolute; + bottom: calc((36px - 24px) / 2); + right: 0.5rem; + transition: all 80ms var(--ease-in-out); + + svg { + width: 16px; + height: 16px; + } + } + + .icon { + display: inline-flex; + justify-content: center; + align-items: center; + width: 36px; + height: 36px; + pointer-events: none; + color: var(--grey-400); + background-color: var(--grey-50); + border-right: var(--input-border); + z-index: 2; + transition: all 80ms var(--ease-in-out); + + svg { + width: 18px; + height: 18px; + } + } + + multiselect-options { + position: fixed; + top: 0; + left: 0; + width: 100%; + background-color: var(--white); + border: var(--input-border); + box-shadow: var(--bevel); + border-radius: 0.5rem; + padding: 0.5rem 0; + opacity: 0; + visibility: hidden; + pointer-events: none; + z-index: 1000; + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-800); + box-shadow: none; + background-color: var(--grey-900); + } + + .search { + background-color: var(--white); + width: 100%; + padding: 0 0.5rem 0.5rem; + margin-bottom: 0.5rem; + border-bottom: var(--input-border); + display: flex; + align-items: center; + flex-flow: row nowrap; + + &:focus-within { + i { + color: var(--grey-500); + } + } + + @media (prefers-color-scheme: dark) { + background-color: var(--grey-900); + border-bottom-color: var(--grey-800); + + &:focus-within { + i { + color: var(--grey-300); + } + } + } + + input { + display: block; + width: 100%; + border: var(--input-border); + border-radius: 0.25rem; + padding: 0 0.5rem 0 calc(14px + 0.5rem); + height: 24px; + font-size: var(--font-xs); + line-height: 1; + transition: all 80ms var(--ease-in-out); + outline: none; + box-shadow: none; + color: var(--grey-700); + + &:focus { + border-color: var(--primary-500); + } + + @media (prefers-color-scheme: dark) { + border-color: hsl(var(--grey-950-hsl) / 0.6); + color: var(--grey-300); + background-color: hsl(var(--grey-950-hsl) / 0.6); + border-radius: 1rem; + padding-left: 2rem; + + &:focus { + border-color: var(--primary-400); + } + } + } + + & > checkbox-component { + position: relative; + width: 24px; + height: 24px; + + label { + min-height: auto; + width: 24px; + height: 24px; + } + } + + & > i { + position: absolute; + top: 5px; + left: calc(1.25rem + 24px); + display: inline-flex; + justify-content: center; + align-items: center; + color: var(--grey-400); + transition: all 80ms var(--ease-in-out); + z-index: 2; + + @media (prefers-color-scheme: dark) { + left: calc(1.5rem + 24px); + } + + svg { + width: 14px; + height: 14px; + } + } + } + + .options { + max-height: 200px; + overflow-y: auto; + padding: 0 0.5rem; + overscroll-behavior: contain; + } + } +} diff --git a/client/src/framework/components/multi-select/multi-select.ts b/client/src/framework/components/multi-select/multi-select.ts new file mode 100644 index 0000000..1cb31d9 --- /dev/null +++ b/client/src/framework/components/multi-select/multi-select.ts @@ -0,0 +1,392 @@ +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import soundscape from "~brixi/controllers/soundscape"; +import "~brixi/components/checkbox/checkbox"; +import { UUID } from "@codewithkyle/uuid"; +import Fuse from "fuse.js"; +import pos from "~brixi/controllers/pos"; +import Component from "~brixi/component"; + +env.css("multi-select"); + +export type MultiSelectOption = { + label: string; + value: string | number; + checked?: boolean; + uid?: string; +}; + +export interface IMultiSelect { + label: string; + icon: string; + instructions: string; + options: Array; + required: boolean; + name: string; + error: string; + disabled: boolean; + query: string; + placeholder: string; + search: "fuzzy" | "strict" | null; + separator: string; +} +export default class MultiSelect extends Component { + private inputId: string; + + constructor() { + super(); + this.inputId = UUID(); + this.stateMachine = { + IDLING: { + ERROR: "ERROR", + DISABLE: "DISABLED", + }, + ERROR: { + RESET: "IDLING", + ERROR: "ERROR", + }, + DISABLED: { + ENABLE: "IDLING", + }, + }; + this.model = { + label: "", + name: "", + icon: "", + instructions: "", + options: [], + required: false, + error: null, + disabled: false, + query: "", + placeholder: "", + search: null, + separator: null, + }; + } + + static get observedAttributes() { + return [ + "data-label", + "data-icon", + "data-instructions", + "data-options", + "data-required", + "data-name", + "data-disabled", + "data-query", + "data-placeholder", + "data-search", + "data-separator", + ]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + for (let i = 0; i < settings.options.length; i++) { + if (!settings.options[i]?.checked) { + settings.options[i].checked = false; + } + settings.options[i].uid = UUID(); + } + this.state = settings?.disabled ? "DISABLED" : "IDLING"; + this.set(settings); + } + + public clearError() { + if (this.state === "ERROR") { + this.trigger("RESET"); + } + } + + public setError(error: string) { + this.set({ + error: error, + }); + this.trigger("ERROR"); + soundscape.play("error"); + } + + public reset(): void { + const updated = this.get(); + for (let i = 0; i < updated.options.length; i++) { + updated.options[i].checked = false; + } + this.set(updated); + } + + public getName() { + return this.model.name; + } + + public getValue() { + const selected = []; + for (let i = 0; i < this.model.options.length; i++) { + if (this.model.options[i].checked) { + selected.push(this.model.options[i].value); + } + } + return selected; + } + + public validate(): boolean { + let isValid = true; + if (this.model.required && !this.hasOneCheck()) { + isValid = false; + this.setError("This field is required."); + } else { + this.clearError(); + } + return isValid; + } + + private hasOneCheck() { + let checked = false; + for (let i = 0; i < this.model.options.length; i++) { + if (this.model.options[i]?.checked) { + checked = true; + break; + } + } + return checked; + } + + private calcSelected() { + let selected = 0; + for (let i = 0; i < this.model.options.length; i++) { + if (this.model.options[i].checked) { + selected++; + } + } + return selected; + } + + private filterOptions(): Array { + let options = [...this.model.options]; + if (this.model.query?.length) { + if (this.model.search === "strict") { + const queryValues = this.model.separator === null ? [this.model.query] : this.model.query.trim().split(this.model.separator); + for (let i = options.length - 1; i >= 0; i--) { + let foundOne = false; + for (let q = 0; q < queryValues.length; q++) { + if (options[i].value.toString().toLowerCase().trim() === queryValues[q].toString().toLowerCase().trim()) { + foundOne = true; + break; + } + } + if (!foundOne) { + options.splice(i, 1); + } + } + } else { + const fuse = new Fuse(options, { + ignoreLocation: true, + threshold: 0.0, + keys: ["label"], + }); + const results = fuse.search(this.model.query); + options = []; + for (let i = 0; i < results.length; i++) { + options.push(results[i].item); + } + } + } + return options; + } + + private updateQuery(value: string) { + this.set({ + query: value, + }); + } + private debounceFilterInput = this.debounce(this.updateQuery.bind(this), 300); + private handleFilterInput: EventListener = (e: Event) => { + const target = e.currentTarget as HTMLInputElement; + const value = target.value; + this.debounceFilterInput(value); + }; + + private checkAllCallback: EventListener = (e: CustomEvent) => { + e.stopImmediatePropagation(); + e.preventDefault(); + const { name, checked, value } = e.detail; + const updatedModel = this.get(); + const out = []; + this.querySelectorAll(".options checkbox-component").forEach((checkbox) => { + for (let j = 0; j < updatedModel.options.length; j++) { + // @ts-ignore + if (updatedModel.options[j].uid === checkbox.getName()) { + updatedModel.options[j].checked = checked; + break; + } + } + }); + for (let i = 0; i < updatedModel.options.length; i++) { + if (updatedModel.options[i].checked) { + out.push(updatedModel.options[i].value); + } + } + this.set(updatedModel); + this.validate(); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + name: this.model.name, + value: out, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private checkboxCallback: EventListener = (e: CustomEvent) => { + const { value, name, checked } = e.detail; + const updatedModel = this.get(); + for (let i = 0; i < updatedModel.options.length; i++) { + if (updatedModel.options[i].uid === name) { + updatedModel.options[i].checked = checked; + break; + } + } + const out = []; + for (let j = 0; j < updatedModel.options.length; j++) { + if (updatedModel.options[j].checked) { + out.push(updatedModel.options[j].value); + } + } + this.set(updatedModel); + this.validate(); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + name: this.model.name, + value: out, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + public renderCopy() { + let output: string | TemplateResult = ""; + if (this.state === "IDLING" && this.model.instructions) { + output = html`

    ${unsafeHTML(this.model.instructions)}

    `; + } else if (this.state === "ERROR" && this.model.error) { + output = html`

    ${this.model.error}

    `; + } + return output; + } + + public renderIcon() { + let output: string | TemplateResult = ""; + if (this.model.icon?.length) { + output = html` ${unsafeHTML(this.model.icon)} `; + } + return output; + } + + public renderLabel() { + let output: string | TemplateResult = ""; + if (this.model.label?.length) { + output = html``; + } + return output; + } + + private renderSearch() { + let out: string | TemplateResult = ""; + if (this.model.search !== null) { + out = html` + + `; + } + return out; + } + + render() { + this.setAttribute("state", this.state); + const selected = this.calcSelected(); + const options = this.filterOptions(); + this.tabIndex = 0; + let label: string; + if (selected === this.model.options.length) { + label = "All options selected"; + } else if (selected === 0) { + label = this.model.placeholder || "Select options"; + } else { + label = `${selected} selected`; + } + const view = html` + ${this.renderLabel()} ${this.renderCopy()} + + ${this.renderIcon()} + ${label} + + + + + + + + ${this.renderSearch()} +
    + ${options.map((option) => { + return html` + + `; + })} +
    +
    + `; + render(view, this); + const optionsEl = this.querySelector("multiselect-options") as HTMLElement; + if (optionsEl) { + optionsEl.style.width = `${this.scrollWidth}px`; + pos.positionElementToElement(optionsEl, this, 8); + } + // Workaround for native checkbox getting out of sync with our state + // This is an annoying side effect of using native checkboxes + if (selected > 0) { + // @ts-ignore + this.querySelector(".js-master-checkbox").set({ checked: true }); + } + } +} +env.bind("multi-select-component", MultiSelect); diff --git a/client/src/framework/components/multi-select/readme.md b/client/src/framework/components/multi-select/readme.md new file mode 100644 index 0000000..7f2ac54 --- /dev/null +++ b/client/src/framework/components/multi-select/readme.md @@ -0,0 +1,60 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| options | MultiSelectOption[] | ✅ | +| label | string | | +| icon | string | | +| instructions | string | | +| required | boolean | | +| disabled | boolean | | +| query | string | | +| placeholder | string | | +| search | "fuzzy" or "strict" | | +| separator | string | | + + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type MultiSelectOption = { + label: string; + value: string | number; + checked?: boolean; +}; +``` + +### Event Listeners + +The `change` event will fire when the user checks/unchecks options. + +```typescript +document.body.querySelector('multi-select-component').addEventListener('change', (e) => { + const { name, value } = e.detail; +}); +``` diff --git a/client/src/framework/components/multi-select/static.html b/client/src/framework/components/multi-select/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/notifications/index.html b/client/src/framework/components/notifications/index.html new file mode 100644 index 0000000..d4a3700 --- /dev/null +++ b/client/src/framework/components/notifications/index.html @@ -0,0 +1,39 @@ +
    + + + + + + +
    + + diff --git a/client/src/framework/components/notifications/notifications.scss b/client/src/framework/components/notifications/notifications.scss new file mode 100644 index 0000000..72d6459 --- /dev/null +++ b/client/src/framework/components/notifications/notifications.scss @@ -0,0 +1,273 @@ +notifications-component { + max-height: 100vh; + position: fixed; + top: 0; + right: -300px; + z-index: 9000; + overflow-y: auto; + overflow-x: visible; + padding: 1rem; + width: calc(300px + 2rem + 300px); + pointer-events: none; + + notification-component { + background-color: var(--white); + padding: 1rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-black-md); + display: flex; + flex-flow: row nowrap; + border-style: solid; + border-width: 2px; + border-color: var(--primary-500); + width: 300px; + margin: 0 0 1rem; + transform-origin: top left; + transition: all 300ms var(--ease-in-out); + pointer-events: all; + position: relative; + transform: translateX(100%); + opacity: 0; + animation: grow 300ms forwards var(--ease-in); + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--grey-900-hsl) / 0.87); + backdrop-filter: blur(8px); + } + + &.-yellow { + border-color: var(--warning-400); + + i { + color: var(--warning-500); + + @media (prefers-color-scheme: dark) { + color: var(--warning-400); + } + } + } + + &.-red { + border-color: var(--danger-500); + + i { + color: var(--danger-500); + + @media (prefers-color-scheme: dark) { + color: var(--danger-400); + } + } + } + + &.-green { + border-color: var(--success-500); + + i { + color: var(--success-500); + + @media (prefers-color-scheme: dark) { + color: var(--success-400); + } + } + } + + i { + width: 24px; + height: 24px; + position: relative; + display: inline-flex; + justify-content: center; + align-items: center; + margin-right: 1rem; + color: var(--primary-500); + + @media (prefers-color-scheme: dark) { + color: var(--primary-400); + } + + svg { + width: 22px; + height: 22px; + } + } + + copy-wrapper { + display: block; + flex: 1; + + h3 { + display: block; + color: var(--grey-800); + font-weight: var(--font-medium); + margin-bottom: 0.5rem; + font-size: var(--font-sm); + } + + p { + display: block; + color: var(--grey-700); + font-size: var(--font-xs); + line-height: 1.375; + } + + @media (prefers-color-scheme: dark) { + h3 { + color: var(--white); + } + p { + color: var(--grey-300); + } + } + } + + .close { + width: 24px; + height: 24px; + position: absolute; + top: 0.25rem; + right: 0.25rem; + display: flex; + justify-content: center; + align-items: center; + outline-offset: 0; + color: var(--grey-500); + + @media (prefers-color-scheme: dark) { + color: var(--white); + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + transition: outline-offset 80ms var(--ease-in-out); + } + + &:hover { + outline-offset: 0; + + &::before { + opacity: 0.05; + } + } + + &:active { + outline-offset: 0; + + &::before { + opacity: 0.1; + } + } + + &::before { + content: ""; + display: inline-block; + width: 100%; + height: 100%; + border-radius: 50%; + background-color: var(--grey-500); + opacity: 0; + position: absolute; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + background-color: var(--white); + } + } + + svg { + width: 14px; + height: 14px; + } + } + + notification-timer { + position: absolute; + background-color: var(--white); + + &.vertical { + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.07; + transform-origin: bottom center; + z-index: 1; + } + &.horizontal { + opacity: 0.3; + bottom: 0; + left: 0; + height: 6px; + width: 100%; + transform-origin: right center; + } + } + + notification-actions { + display: flex; + width: 100%; + width: 100%; + flex-flow: row wrap; + align-items: center; + justify-content: flex-end; + margin-top: 0.75rem; + + button { + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + padding: 0 0.5rem; + color: var(--primary-700); + margin-left: 0.5rem; + position: relative; + outline-offset: 0; + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + transition: outline-offset 80ms var(--ease-in-out); + } + + &:hover { + &::before { + opacity: 0.05; + } + } + &:active { + outline-offset: 0; + + &::before { + opacity: 0.1; + } + } + &::before { + content: ""; + display: inline-block; + width: 100%; + height: 100%; + border-radius: 0.25rem; + background-color: var(--primary-500); + position: absolute; + opacity: 0; + top: 0; + left: 0; + transition: all 80ms var(--ease-in-out); + } + } + } + } +} +@keyframes grow { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} diff --git a/client/src/framework/components/notifications/readme.md b/client/src/framework/components/notifications/readme.md new file mode 100644 index 0000000..ae67df3 --- /dev/null +++ b/client/src/framework/components/notifications/readme.md @@ -0,0 +1,30 @@ +Notification messages can be created by importing the static `notifications` class and calling one of the following functions. + +```typescript +import notifications from "~brixi/controllers/alerts"; + +notifications.alert("Something happened", "You did something that triggered this response."); + +notifications.success("Success!", "Your request has been process."); + +notifications.warn("Oh no", "Something is preventing us form processing your request."); + +notifications.error("Error", "Your request has failed with a status code of 0x0001"); +``` + +Notification messages also accept an array of actions. + +```typescript +notifications.alert( + "Something happened", + "You did something that triggered this response.", + [ + { + label: "go to page", + callback: () => { + // ...snip... + }, + }, + ] +); +``` diff --git a/client/src/framework/components/notifications/static.html b/client/src/framework/components/notifications/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/overflow-menu/index.html b/client/src/framework/components/overflow-menu/index.html new file mode 100644 index 0000000..2caccec --- /dev/null +++ b/client/src/framework/components/overflow-menu/index.html @@ -0,0 +1,33 @@ + + + + diff --git a/client/src/framework/components/overflow-menu/overflow-menu.scss b/client/src/framework/components/overflow-menu/overflow-menu.scss new file mode 100644 index 0000000..4e18c97 --- /dev/null +++ b/client/src/framework/components/overflow-menu/overflow-menu.scss @@ -0,0 +1,140 @@ +overflow-menu { + border-radius: 0.25rem; + background-color: var(--white); + border: 1px solid var(--grey-300); + box-shadow: var(--shadow-black-sm); + padding: 0.25rem 0; + display: inline-block; + position: fixed; + z-index: 1000; + top: 0; + left: 0; + + @media (prefers-color-scheme: dark) { + background-color: var(--grey-900); + border-color: var(--grey-800); + } + + &.is-visible { + pointer-events: all; + opacity: 1; + visibility: visible; + } + + button { + width: 100%; + display: flex; + align-items: center; + flex-flow: row nowrap; + min-height: 36px; + padding: 0 1rem; + color: var(--grey-700); + position: relative; + font-size: var(--font-sm); + font-weight: var(--font-medium); + line-height: 1; + white-space: nowrap; + outline-offset: 0; + + @media (prefers-color-scheme: dark) { + color: var(--grey-400); + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + transition: outline-offset 80ms var(--ease-in-out); + } + + &:hover, + &:focus-visible { + &::before { + opacity: 0.05; + } + } + + &:active { + outline-offset: 0; + + &::before { + opacity: 0.1; + } + } + + &::before { + content: ""; + display: inline-block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + background-color: var(--grey-500); + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + background-color: var(--grey-300); + } + } + + &.danger { + color: var(--danger-700); + + &::before { + background-color: var(--danger-500); + } + + i { + svg { + color: var(--danger-700); + } + } + + @media (prefers-color-scheme: dark) { + color: var(--danger-400); + + &::before { + background-color: var(--danger-400); + } + + i { + svg { + color: var(--danger-400); + } + } + } + } + + i { + display: inline-flex; + justify-content: center; + align-items: center; + margin-right: 0.5rem; + margin-left: -0.375rem; + width: 24px; + height: 24px; + + svg { + width: 18px; + height: 18px; + color: var(--grey-600); + + @media (prefers-color-scheme: dark) { + color: var(--grey-400); + } + } + } + } + + hr { + display: block; + width: 100%; + margin: 0.25rem 0; + border-bottom: 1px solid var(--grey-300); + + @media (prefers-color-scheme: dark) { + border-bottom-color: var(--grey-800); + } + } +} diff --git a/client/src/framework/components/overflow-menu/overflow-menu.ts b/client/src/framework/components/overflow-menu/overflow-menu.ts new file mode 100644 index 0000000..ec4f246 --- /dev/null +++ b/client/src/framework/components/overflow-menu/overflow-menu.ts @@ -0,0 +1,95 @@ +import { html, render } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import Component from "~brixi/component"; +import env from "~brixi/controllers/env"; +import pos from "~brixi/controllers/pos"; +import { noop } from "~brixi/utils/general"; + +env.css("overflow-menu"); + +export interface OverflowItem { + label: string; + id: string; + icon?: string; + danger?: boolean; +} +export interface IOverflowMenu { + items: Array; + uid: string; + offset?: number; + target: HTMLElement; + callback: (id: string) => void; +} +export default class OverflowMenu extends Component { + constructor(settings: IOverflowMenu) { + super(); + this.model = { + items: [], + uid: null, + offset: 0, + target: null, + callback: noop, + }; + this.set(settings); + } + + override connected() { + document.addEventListener( + "click", + () => { + this.remove(); + }, + { passive: true, capture: true } + ); + window.addEventListener( + "resize", + () => { + this.remove(); + }, + { passive: true, capture: true } + ); + window.addEventListener( + "scroll", + () => { + this.remove(); + }, + { passive: true, capture: true } + ); + this.addEventListener("click", (e: Event) => { + e.stopImmediatePropagation(); + }); + } + + private handleItemClick: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + // @ts-ignore + this.model.callback(e.currentTarget.dataset.id); + }; + + private renderItem(item: OverflowItem) { + if (item === null) { + return html`
    `; + } + return html` + + `; + } + + override render() { + if (!this.isConnected) { + document.body.appendChild(this); + } + this.setAttribute("overflow-menu-container-id", this.model.uid); + const view = html` + ${this.model.items.map((item) => { + return this.renderItem(item); + })} + `; + render(view, this); + pos.positionElementToElement(this, this.model.target, this.model.offset); + } +} +env.bind("overflow-menu", OverflowMenu); diff --git a/client/src/framework/components/overflow-menu/readme.md b/client/src/framework/components/overflow-menu/readme.md new file mode 100644 index 0000000..9575464 --- /dev/null +++ b/client/src/framework/components/overflow-menu/readme.md @@ -0,0 +1,50 @@ +```typescript +import OverflowMenu from "~brixi/components/overflow-menu/overflow-menu"; +new OverflowMenu({ + items: [ + { + label: "Example", + id: "example", + }, + { + label: "Example", + icon: ``, + id: "example2", + }, + null, + { + danger: true, + label: "Example", + icon: ``, + id: "example3", + }, + ], + target: this, + callback: (id) => { + console.log(id); + } +}); +``` + +> **Note**: you can render `null` instead of a `ContextItem` to render a horizontal rule. + +### Data Attributes + +| Key | Type | Required | +| --- | ---- | -------- | +| items | OverflowItem[] | ✅ | +| callback | `(id: string) => void` | ✅ | +| target | HTMLElement | ✅ | +| uid | string | ✅ | +| offset | number | | + +### Types + +```typescript +type OverflowItem = { + label: string; + id: string; + icon?: string; + danger?: boolean; +} +``` diff --git a/client/src/framework/components/overflow-menu/static.html b/client/src/framework/components/overflow-menu/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/pagination/index.html b/client/src/framework/components/pagination/index.html new file mode 100644 index 0000000..c8cb932 --- /dev/null +++ b/client/src/framework/components/pagination/index.html @@ -0,0 +1,9 @@ + + + diff --git a/client/src/framework/components/pagination/pagination.scss b/client/src/framework/components/pagination/pagination.scss new file mode 100644 index 0000000..e7088a9 --- /dev/null +++ b/client/src/framework/components/pagination/pagination.scss @@ -0,0 +1,7 @@ +pagination-component { + display: inline-flex; + flex-flow: row nowrap; + overflow-x: auto; + align-items: center; + position: relative; +} diff --git a/client/src/framework/components/pagination/pagination.ts b/client/src/framework/components/pagination/pagination.ts new file mode 100644 index 0000000..ffa631e --- /dev/null +++ b/client/src/framework/components/pagination/pagination.ts @@ -0,0 +1,124 @@ +import { html, render } from "lit-html"; +import env from "~brixi/controllers/env"; +import "~brixi/components/buttons/button/button"; +import Component from "~brixi/component"; +import { parseDataset } from "~brixi/utils/general"; + +env.css(["pagination", "button"]); + +export interface IPagination { + totalPages: number; + activePage: number; +} +export default class Pagination extends Component { + constructor() { + super(); + this.model = { + totalPages: 0, + activePage: 1, + }; + } + + static get observedAttributes() { + return ["data-total-pages", "data-active-page"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + public back(): void { + this.processPageChange(this.model.activePage - 1); + } + public forward(): void { + this.processPageChange(this.model.activePage + 1); + } + public jumpToPage(pageNumber: number): void { + this.processPageChange(pageNumber); + } + + private handleBack: EventListener = () => { + this.back(); + }; + private handleForward: EventListener = () => { + this.forward(); + }; + + private processPageChange(nextPageNumber: number): void { + const updated = this.get(); + updated.activePage = nextPageNumber; + if (updated.activePage < 1) { + updated.activePage = 1; + } else if (updated.activePage > updated.totalPages) { + updated.activePage = updated.totalPages; + } + this.set(updated); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + page: updated.activePage, + }, + bubbles: true, + cancelable: true, + }) + ); + } + + private calcVisiblePageNumbers(): number[] { + let out = []; + if (this.model.totalPages <= 5) { + for (let i = 1; i <= this.model.totalPages; i++) { + out.push(i); + } + } else if (this.model.activePage <= 2) { + out = [1, 2, 3, 4, 5]; + } else if (this.model.activePage >= this.model.totalPages - 2) { + out = [this.model.totalPages - 4, this.model.totalPages - 3, this.model.totalPages - 2, this.model.totalPages - 1, this.model.totalPages]; + } else { + out = [this.model.activePage - 2, this.model.activePage - 1, this.model.activePage, this.model.activePage + 1, this.model.activePage + 2]; + } + return out; + } + + override render() { + const visiblePageNumbers: number[] = this.calcVisiblePageNumbers(); + const view = html` + + ${visiblePageNumbers.map((pageNumber) => { + return html` + + `; + })} + + `; + render(view, this); + } +} +env.bind("pagination-component", Pagination); diff --git a/client/src/framework/components/pagination/readme.md b/client/src/framework/components/pagination/readme.md new file mode 100644 index 0000000..06b4066 --- /dev/null +++ b/client/src/framework/components/pagination/readme.md @@ -0,0 +1,25 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| totalPages | number | ✅ | +| activePage | number | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Event Listeners + +The `change` event will fire when the user clicks to change pages. + +```typescript +document.body.querySelector('pagination-component').addEventListener('change', (e) => { + console.error(e.detail.page); +}); +``` diff --git a/client/src/framework/components/pagination/static.html b/client/src/framework/components/pagination/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/progress/progress-badge/index.html b/client/src/framework/components/progress/progress-badge/index.html new file mode 100644 index 0000000..6ba7888 --- /dev/null +++ b/client/src/framework/components/progress/progress-badge/index.html @@ -0,0 +1,39 @@ + + + + + + + diff --git a/client/src/framework/components/progress/progress-badge/progress-badge.scss b/client/src/framework/components/progress/progress-badge/progress-badge.scss new file mode 100644 index 0000000..8e3e2cb --- /dev/null +++ b/client/src/framework/components/progress/progress-badge/progress-badge.scss @@ -0,0 +1,108 @@ +progress-badge { + position: relative; + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + justify-content: center; + height: 24px; + line-height: 24px; + padding: 0 0.75rem 0 0.25rem; + font-size: var(--font-sm); + font-weight: var(--font-medium); + background-color: hsl(var(--primary-500-hsl) / 0.1); + border-radius: 0.75rem; + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--primary-300-hsl) / 0.05); + } + + &[color="grey"] { + background-color: hsl(var(--grey-500-hsl) / 0.1); + + span { + color: var(--grey-800); + } + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--grey-300-hsl) / 0.05); + + span { + color: var(--grey-400); + } + } + } + + &[color="primary"] { + background-color: hsl(var(--primary-500-hsl) / 0.1); + + span { + color: var(--primary-800); + } + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--primary-300-hsl) / 0.05); + + span { + color: var(--primary-400); + } + } + } + + &[color="danger"] { + background-color: hsl(var(--danger-500-hsl) / 0.1); + + span { + color: var(--danger-800); + } + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--danger-300-hsl) / 0.05); + + span { + color: var(--danger-400); + } + } + } + + &[color="warning"] { + background-color: hsl(var(--warning-500-hsl) / 0.1); + + span { + color: var(--warning-800); + } + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--warning-300-hsl) / 0.05); + + span { + color: var(--warning-400); + } + } + } + + &[color="success"] { + background-color: hsl(var(--success-500-hsl) / 0.1); + + span { + color: var(--success-800); + } + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--success-300-hsl) / 0.05); + + span { + color: var(--success-400); + } + } + } + + span { + display: inline-block; + margin-left: 0.25rem; + color: var(--grey-700); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } +} diff --git a/client/src/framework/components/progress/progress-badge/progress-badge.ts b/client/src/framework/components/progress/progress-badge/progress-badge.ts new file mode 100644 index 0000000..2139fd1 --- /dev/null +++ b/client/src/framework/components/progress/progress-badge/progress-badge.ts @@ -0,0 +1,61 @@ +import { html, render } from "lit-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import "~brixi/components/progress/progress-indicator/progress-indicator"; +import Component from "~brixi/component"; + +env.css(["progress-badge"]); + +export interface IProgressBadge { + label: string; + total: number; + color: "grey" | "primary" | "success" | "warning" | "danger"; +} +export default class ProgressBadge extends Component { + private indicator: HTMLElement; + + constructor() { + super(); + this.indicator = null; + this.model = { + label: "", + total: 1, + color: "grey", + }; + } + + static get observedAttributes() { + return ["data-label", "data-total", "data-color"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + public tick(): void { + if (!this.indicator) { + this.indicator = this.querySelector("progress-indicator"); + } + // @ts-ignore + this.indicator?.tick(); + } + + public reset(): void { + if (!this.indicator) { + this.indicator = this.querySelector("progress-indicator"); + } + // @ts-ignore + this.indicator?.reset(); + } + + override render() { + this.setAttribute("color", this.model.color); + const view = html` + + ${this.model.label} + `; + render(view, this); + } +} +env.bind("progress-badge", ProgressBadge); diff --git a/client/src/framework/components/progress/progress-badge/readme.md b/client/src/framework/components/progress/progress-badge/readme.md new file mode 100644 index 0000000..a8034be --- /dev/null +++ b/client/src/framework/components/progress/progress-badge/readme.md @@ -0,0 +1,41 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| total | number | ✅ | +| label | string | ✅ | +| color | Color | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type Color = "grey" | "primary" | "success" | "warning" | "danger"; +``` + +### Event Listeners + +The `tick` event will fire when the `tick()` method is called. + +```typescript +document.body.querySelector('progress-badge').addEventListener('tick', (e) => { + console.error(e.detail.tick); // Current tick +}); +``` + +The `finished` event will fire when the desired (total) number of ticks has been reached. + +```typescript +document.body.querySelector('progress-badge').addEventListener('finished', (e) => { + // ...snip... +}); +``` diff --git a/client/src/framework/components/progress/progress-badge/static.html b/client/src/framework/components/progress/progress-badge/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/progress/progress-indicator/index.html b/client/src/framework/components/progress/progress-indicator/index.html new file mode 100644 index 0000000..4d5bb1c --- /dev/null +++ b/client/src/framework/components/progress/progress-indicator/index.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/client/src/framework/components/progress/progress-indicator/progress-indicator.scss b/client/src/framework/components/progress/progress-indicator/progress-indicator.scss new file mode 100644 index 0000000..5d0eed1 --- /dev/null +++ b/client/src/framework/components/progress/progress-indicator/progress-indicator.scss @@ -0,0 +1,109 @@ +progress-indicator { + display: block; + position: relative; + cursor: progress; + + svg { + position: absolute; + width: 100%; + height: 100%; + + &[color="white"] { + .inner { + color: var(--white); + } + .outter { + color: var(--white); + } + } + + &[color="primary"] { + .inner { + color: var(--primary-500); + + @media (prefers-color-scheme: dark) { + color: var(--primary-300); + } + } + .outter { + color: var(--primary-500); + + @media (prefers-color-scheme: dark) { + color: var(--primary-400); + } + } + } + + &[color="success"] { + .inner { + color: var(--success-500); + + @media (prefers-color-scheme: dark) { + color: var(--success-300); + } + } + .outter { + color: var(--success-500); + + @media (prefers-color-scheme: dark) { + color: var(--success-400); + } + } + } + + &[color="warning"] { + .inner { + color: var(--warning-500); + + @media (prefers-color-scheme: dark) { + color: var(--warning-300); + } + } + .outter { + color: var(--warning-400); + + @media (prefers-color-scheme: dark) { + color: var(--warning-400); + } + } + } + + &[color="danger"] { + .inner { + color: var(--danger-500); + + @media (prefers-color-scheme: dark) { + color: var(--danger-300); + } + } + .outter { + color: var(--danger-500); + + @media (prefers-color-scheme: dark) { + color: var(--danger-400); + } + } + } + + .inner { + color: var(--grey-500); + opacity: 0.3; + + @media (prefers-color-scheme: dark) { + opacity: 0.15; + color: var(--grey-400); + } + } + + .outter { + stroke-dashoffset: 70; + stroke-dasharray: 70; + color: var(--grey-500); + transition: all 150ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + color: var(--grey-400); + } + } + } +} diff --git a/client/src/framework/components/progress/progress-indicator/progress-indicator.ts b/client/src/framework/components/progress/progress-indicator/progress-indicator.ts new file mode 100644 index 0000000..fe29a72 --- /dev/null +++ b/client/src/framework/components/progress/progress-indicator/progress-indicator.ts @@ -0,0 +1,117 @@ +import { html, render } from "lit-html"; +import Component from "~brixi/component"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import { calcPercent } from "~brixi/utils/numpy"; + +env.css(["progress-indicator"]); + +export interface IProgressIndicator { + size: number; + tick: number; + total: number; + color: "grey" | "primary" | "success" | "warning" | "danger" | "white"; +} +export default class ProgressIndicator extends Component { + constructor() { + super(); + this.model = { + size: 24, + tick: 0, + total: 1, + color: "grey", + }; + } + + static get observedAttributes() { + return ["data-size", "data-tick", "data-total", "data-color"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + /** + * Resets the `tick` value to `0`. + */ + public reset(): void { + this.set({ + tick: 0, + }); + } + + public tick(amount = 1): void { + const updatedModel = this.get(); + if (updatedModel.tick < updatedModel.total) { + updatedModel.tick += amount; + this.set(updatedModel, true); + this.render(); + if (updatedModel.tick >= updatedModel.total) { + this.dispatchEvent( + new CustomEvent("finished", { + bubbles: true, + cancelable: true, + }) + ); + } else { + this.dispatchEvent( + new CustomEvent("tick", { + detail: { + tick: updatedModel.tick, + }, + bubbles: true, + cancelable: true, + }) + ); + } + } + } + + /** + * Sets the total and resets the `tick` value to `0`. + */ + public setTotal(total: number): void { + this.set( + { + total: total, + tick: 0, + }, + true + ); + } + + private calcDashOffset(): number { + const percent = this.model.tick / this.model.total; + let offset = Math.round(70 - 70 * percent + 2); + if (offset >= 70 && this.model.tick > 0) { + offset = 69; + } else if (offset > 70) { + offset = 70; + } + return offset; + } + + override render() { + this.style.width = `${this.model.size}px`; + this.style.height = `${this.model.size}px`; + this.setAttribute("tooltip", `${calcPercent(this.model.tick, this.model.total)}%`); + const view = html` + + + + + `; + render(view, this); + } +} +env.bind("progress-indicator", ProgressIndicator); diff --git a/client/src/framework/components/progress/progress-indicator/readme.md b/client/src/framework/components/progress/progress-indicator/readme.md new file mode 100644 index 0000000..c960cd4 --- /dev/null +++ b/client/src/framework/components/progress/progress-indicator/readme.md @@ -0,0 +1,42 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| total | number | ✅ | +| tick | number | | +| size | number | | +| color | Color | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type Color = "grey" | "primary" | "success" | "warning" | "danger"; +``` + +### Event Listeners + +The `tick` event will fire when the `tick()` method is called. + +```typescript +document.body.querySelector('progress-indicator').addEventListener('tick', (e) => { + console.error(e.detail.tick); // Current tick +}); +``` + +The `finished` event will fire when the desired (total) number of ticks has been reached. + +```typescript +document.body.querySelector('progress-indicator').addEventListener('finished', (e) => { + // ...snip... +}); +``` diff --git a/client/src/framework/components/progress/progress-indicator/static.html b/client/src/framework/components/progress/progress-indicator/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/progress/progress-label/index.html b/client/src/framework/components/progress/progress-label/index.html new file mode 100644 index 0000000..f74109d --- /dev/null +++ b/client/src/framework/components/progress/progress-label/index.html @@ -0,0 +1,19 @@ + + + diff --git a/client/src/framework/components/progress/progress-label/progress-label.scss b/client/src/framework/components/progress/progress-label/progress-label.scss new file mode 100644 index 0000000..ad06b2f --- /dev/null +++ b/client/src/framework/components/progress/progress-label/progress-label.scss @@ -0,0 +1,6 @@ +progress-label { + display: flex; + position: relative; + flex-flow: row nowrap; + align-items: center; +} diff --git a/client/src/framework/components/progress/progress-label/progress-label.ts b/client/src/framework/components/progress/progress-label/progress-label.ts new file mode 100644 index 0000000..a53af73 --- /dev/null +++ b/client/src/framework/components/progress/progress-label/progress-label.ts @@ -0,0 +1,70 @@ +import { html, render } from "lit-html"; +import Component from "~brixi/component"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import "../progress-indicator/progress-indicator"; + +env.css(["progress-label"]); + +export interface IProgressLabel { + title: string; + subtitle: string; + total: number; +} +export default class ProgressLabel extends Component { + private indicator: HTMLElement; + + constructor() { + super(); + this.indicator = null; + this.model = { + total: 1, + title: "", + subtitle: "", + }; + } + + static get observedAttributes() { + return ["data-title", "data-subtitle", "data-total"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + public tick(): void { + if (!this.indicator) { + this.indicator = this.querySelector("progress-indicator"); + } + // @ts-ignore + this.indicator?.tick(); + } + + public reset(): void { + if (!this.indicator) { + this.indicator = this.querySelector("progress-indicator"); + } + // @ts-ignore + this.indicator?.reset(); + } + + public setProgress(subtitle: string): void { + const el: HTMLElement = this.querySelector("h3"); + el.classList.remove("none"); + el.classList.add("block"); + el.innerText = subtitle; + } + + override render() { + const view = html` + +
    +

    ${this.model.title}

    +

    ${this.model.subtitle}

    +
    + `; + render(view, this); + } +} +env.bind("progress-label", ProgressLabel); diff --git a/client/src/framework/components/progress/progress-label/readme.md b/client/src/framework/components/progress/progress-label/readme.md new file mode 100644 index 0000000..f6d6530 --- /dev/null +++ b/client/src/framework/components/progress/progress-label/readme.md @@ -0,0 +1,35 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| total | number | ✅ | +| title | string | ✅ | +| subtitle | string | ✅ | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Event Listeners + +The `tick` event will fire when the `tick()` method is called. + +```typescript +document.body.querySelector('progress-label').addEventListener('tick', (e) => { + console.error(e.detail.tick); // Current tick +}); +``` + +The `finished` event will fire when the desired (total) number of ticks has been reached. + +```typescript +document.body.querySelector('progress-label').addEventListener('finished', (e) => { + // ...snip... +}); +``` diff --git a/client/src/framework/components/progress/progress-label/static.html b/client/src/framework/components/progress/progress-label/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/progress/progress-toast/index.html b/client/src/framework/components/progress/progress-toast/index.html new file mode 100644 index 0000000..94f311b --- /dev/null +++ b/client/src/framework/components/progress/progress-toast/index.html @@ -0,0 +1,15 @@ + + + diff --git a/client/src/framework/components/progress/progress-toast/progress-toast.scss b/client/src/framework/components/progress/progress-toast/progress-toast.scss new file mode 100644 index 0000000..f875cc4 --- /dev/null +++ b/client/src/framework/components/progress/progress-toast/progress-toast.scss @@ -0,0 +1,33 @@ +progress-toast { + background-color: var(--white); + padding: 1rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-black-md); + display: flex; + flex-flow: row nowrap; + width: 300px; + margin: 0 0 1rem; + transform-origin: top left; + transition: all 300ms var(--ease-in-out); + pointer-events: all; + position: relative; + transform: translateX(100%); + opacity: 0; + animation: grow 300ms forwards var(--ease-in); + border: 2px solid var(--grey-300); + + @media (prefers-color-scheme: dark) { + background-color: var(--grey-900); + border-color: var(--grey-400); + } +} +@keyframes grow { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} diff --git a/client/src/framework/components/progress/progress-toast/progress-toast.ts b/client/src/framework/components/progress/progress-toast/progress-toast.ts new file mode 100644 index 0000000..99adc5c --- /dev/null +++ b/client/src/framework/components/progress/progress-toast/progress-toast.ts @@ -0,0 +1,74 @@ +import { html, render } from "lit-html"; +import Component from "~brixi/component"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import "../progress-indicator/progress-indicator"; + +env.css(["progress-toast"]); + +export interface IProgressToast { + title: string; + subtitle: string; + total: number; +} +export default class ProgressToast extends Component { + private indicator: HTMLElement; + + constructor() { + super(); + this.indicator = null; + this.model = { + total: 1, + title: "", + subtitle: "", + }; + } + + static get observedAttributes() { + return ["data-title", "data-subtitle", "data-total"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + public tick(amount = 1): void { + if (!this.indicator) { + this.indicator = this.querySelector("progress-indicator"); + } + // @ts-ignore + this.indicator?.tick(amount); + } + + public reset(): void { + if (!this.indicator) { + this.indicator = this.querySelector("progress-indicator"); + } + // @ts-ignore + this.indicator?.reset(); + } + + public setProgress(subtitle: string): void { + const el: HTMLElement = this.querySelector("h3"); + el.classList.remove("none"); + el.classList.add("block"); + el.innerText = subtitle; + } + + private finishedCallback: EventListener = () => { + this.remove(); + }; + + override render() { + const view = html` + +
    +

    ${this.model.title}

    +

    ${this.model.subtitle}

    +
    + `; + render(view, this); + } +} +env.bind("progress-toast", ProgressToast); diff --git a/client/src/framework/components/progress/progress-toast/readme.md b/client/src/framework/components/progress/progress-toast/readme.md new file mode 100644 index 0000000..126c83c --- /dev/null +++ b/client/src/framework/components/progress/progress-toast/readme.md @@ -0,0 +1,28 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| total | number | ✅ | +| title | string | ✅ | +| subtitle | string | ✅ | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Event Listeners + +The `tick` event will fire when the `tick()` method is called. + +```typescript +document.body.querySelector('progress-toast').addEventListener('tick', (e) => { + console.error(e.detail.tick); // Current tick +}); +``` + diff --git a/client/src/framework/components/progress/progress-toast/static.html b/client/src/framework/components/progress/progress-toast/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/progress/spinner/index.html b/client/src/framework/components/progress/spinner/index.html new file mode 100644 index 0000000..47ab687 --- /dev/null +++ b/client/src/framework/components/progress/spinner/index.html @@ -0,0 +1,8 @@ + + diff --git a/client/src/framework/components/progress/spinner/readme.md b/client/src/framework/components/progress/spinner/readme.md new file mode 100644 index 0000000..db499e2 --- /dev/null +++ b/client/src/framework/components/progress/spinner/readme.md @@ -0,0 +1,22 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| size | number | | +| color| Color | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type Color = "primary" | "grey" | "white"; +``` + diff --git a/client/src/framework/components/progress/spinner/spinner.scss b/client/src/framework/components/progress/spinner/spinner.scss new file mode 100644 index 0000000..1af7008 --- /dev/null +++ b/client/src/framework/components/progress/spinner/spinner.scss @@ -0,0 +1,35 @@ +spinner-component { + display: inline-flex; + justify-content: center; + align-items: center; + + svg { + width: 100%; + height: 100%; + animation: spinner 800ms linear infinite; + + &[color="primary"] { + color: var(--primary-500); + + @media (prefers-color-scheme: dark) { + color: var(--primary-300); + } + } + + &[color="grey"] { + color: var(--grey-500); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + } +} +@keyframes spinner { + from { + transform: rotate(0); + } + to { + transform: rotate(360deg); + } +} diff --git a/client/src/framework/components/progress/spinner/spinner.ts b/client/src/framework/components/progress/spinner/spinner.ts new file mode 100644 index 0000000..31b947f --- /dev/null +++ b/client/src/framework/components/progress/spinner/spinner.ts @@ -0,0 +1,53 @@ +import { html, render } from "lit-html"; +import Component from "~brixi/component"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; + +env.css(["spinner"]); + +export interface ISpinner { + color: "primary" | "grey"; + size: number; +} +export default class Spinner extends Component { + constructor() { + super(); + this.model = { + color: "grey", + size: 18, + }; + } + + static get observedAttributes() { + return ["data-color", "data-size"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + override render() { + this.style.width = ``; + this.style.height = `${this.model.size}px`; + const view = html` + + `; + render(view, this); + } +} +env.bind("spinner-component", Spinner); diff --git a/client/src/framework/components/progress/spinner/static.html b/client/src/framework/components/progress/spinner/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/radio-group/index.html b/client/src/framework/components/radio-group/index.html new file mode 100644 index 0000000..475e1c4 --- /dev/null +++ b/client/src/framework/components/radio-group/index.html @@ -0,0 +1,21 @@ + + + + + diff --git a/client/src/framework/components/radio-group/radio-group.scss b/client/src/framework/components/radio-group/radio-group.scss new file mode 100644 index 0000000..a1904d6 --- /dev/null +++ b/client/src/framework/components/radio-group/radio-group.scss @@ -0,0 +1,55 @@ +radio-group { + display: block; + width: 100%; + position: relative; + + &[state="DISABLED"] { + cursor: not-allowed !important; + + p { + opacity: 0.6; + color: var(--grey-400) !important; + + strong { + color: var(--grey-400) !important; + } + } + + @media (prefers-color-scheme: dark) { + p { + opacity: 0.3; + color: var(--grey-300) !important; + + strong { + color: var(--grey-300) !important; + } + } + } + } + + p { + display: block; + margin-bottom: 0.5rem; + font-size: var(--font-xs); + color: var(--grey-700); + line-height: 1.375; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + + strong { + display: block; + width: 100%; + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--grey-800); + margin-bottom: 0.5rem; + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + } +} diff --git a/client/src/framework/components/radio-group/radio-group.ts b/client/src/framework/components/radio-group/radio-group.ts new file mode 100644 index 0000000..1c4e80b --- /dev/null +++ b/client/src/framework/components/radio-group/radio-group.ts @@ -0,0 +1,129 @@ +import { html, render } from "lit-html"; +import env from "~brixi/controllers/env"; +import "~brixi/components/radio/radio"; +import { parseDataset } from "~brixi/utils/general"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import soundscape from "~brixi/controllers/soundscape"; +import Component from "~brixi/component"; +import type { IRadio } from "~brixi/components/radio/radio"; + +env.css(["radio-group", "radio"]); + +export interface IRadioGroup { + options: Array; + instructions: string; + disabled: boolean; + label: string; + name: string; + required: boolean; +} +export default class RadioGroup extends Component { + constructor() { + super(); + this.model = { + label: "", + instructions: "", + disabled: false, + name: "", + options: [], + required: false, + }; + } + + static get observedAttributes() { + return ["data-label", "data-instructions", "data-disabled", "data-name", "data-required", "data-options"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + settings.options.map((option) => { + option.name = settings.name; + option.disabled = settings?.disabled ?? false; + }); + this.state = settings.disabled ? "DISABLED" : "IDLING"; + this.set(settings); + } + + public getName(): string { + return this.model.name; + } + + public getValue(): string | number | null { + let value = null; + for (let i = 0; i < this.model.options.length; i++) { + if (this.model.options[i].checked) { + value = this.model.options[i].value; + break; + } + } + return value; + } + + public reset(): void { + const updated = this.get(); + for (let i = 0; i < updated.options.length; i++) { + updated.options[i].checked = false; + } + updated.options[0].checked = true; + this.set(updated); + } + + public clearError(): void { + if (this.state === "ERROR") { + this.trigger("RESET"); + } + } + + public setError(error: string): void { + if (error?.length) { + this.set({ + // @ts-ignore + error: error, + }); + this.trigger("ERROR"); + soundscape.play("error"); + } + } + + public validate(): boolean { + let isValid = true; + if (this.model.required) { + isValid = false; + for (let i = 0; i < this.model.options.length; i++) { + if (this.model.options[i].checked) { + isValid = true; + break; + } + } + if (!isValid) { + this.setError("This field is required"); + } + } + return isValid; + } + + override render() { + this.setAttribute("state", this.state); + this.setAttribute("form-input", ""); + const view = html` +

    + ${this.model.label} + ${unsafeHTML(this.model.instructions)} +

    + ${this.model.options.map((option: IRadio) => { + return html` + + `; + })} + `; + render(view, this); + } +} +env.bind("radio-group", RadioGroup); diff --git a/client/src/framework/components/radio-group/readme.md b/client/src/framework/components/radio-group/readme.md new file mode 100644 index 0000000..036ab8e --- /dev/null +++ b/client/src/framework/components/radio-group/readme.md @@ -0,0 +1,44 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| options | Radio[] | ✅ | +| label | string | | +| instructions | string | | +| requried | boolean | | +| disabled | boolean | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type Radio = { + label: string; + required: boolean; + name: string; + checked: boolean; + disabled: boolean; + value: string | number; +} +``` + +### Event Listeners + +The `change` event will fire as users interact with the radio inputs. + +```typescript +document.body.querySelector('radio-component').addEventListener('change', (e) => { + const { name, value } = e.detail; +}); +``` diff --git a/client/src/framework/components/radio-group/static.html b/client/src/framework/components/radio-group/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/radio/index.html b/client/src/framework/components/radio/index.html new file mode 100644 index 0000000..1a407a7 --- /dev/null +++ b/client/src/framework/components/radio/index.html @@ -0,0 +1,19 @@ + + + + + diff --git a/client/src/framework/components/radio/radio.scss b/client/src/framework/components/radio/radio.scss new file mode 100644 index 0000000..4d28797 --- /dev/null +++ b/client/src/framework/components/radio/radio.scss @@ -0,0 +1,186 @@ +radio-component { + display: block; + width: 100%; + position: relative; + + &.is-active { + label { + i { + outline-offset: 0 !important; + + &::before { + border-color: var(--primary-500) !important; + background-color: var(--white); + } + + &::after { + transform: scale(1.15) !important; + background-color: var(--primary-500); + } + } + } + } + + input { + position: absolute; + top: 0; + left: 0; + opacity: 0; + visibility: hidden; + + &:checked + label { + i { + &::before { + border-color: var(--primary-700); + background-color: var(--white); + + @media (prefers-color-scheme: dark) { + border-color: var(--primary-400); + background-color: transparent; + } + } + + &::after { + transform: scale(1); + } + } + } + + &:disabled + label { + cursor: not-allowed !important; + opacity: 0.6; + + i { + cursor: not-allowed !important; + + &::before { + background-color: var(--grey-50) !important; + border-color: var(--grey-200) !important; + + @media (prefers-color-scheme: dark) { + background-color: transparent !important; + border-color: var(--grey-700) !important; + } + } + + &::after { + background-color: var(--grey-200) !important; + box-shadow: var(--bevel) !important; + } + } + + span { + color: var(--grey-400) !important; + } + } + } + + label { + display: flex; + align-items: center; + width: 100%; + min-height: 36px; + cursor: pointer; + + &:hover, + &:focus-within { + i { + &::after { + transform: scale(0.5); + } + } + } + + &:active { + i { + &::after { + transform: scale(1.25); + background-color: var(--primary-500); + } + + &::before { + border-color: var(--primary-500); + } + } + } + + i { + width: 24px; + height: 24px; + margin-right: 0.5rem; + display: inline-flex; + justify-content: center; + align-items: center; + cursor: pointer; + outline-offset: 0; + border-radius: 50%; + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + transition: outline-offset 80ms var(--ease-in-out); + } + + &:active { + outline-offset: 0; + + &::before { + border-color: var(--primary-500) !important; + background-color: var(--white); + } + + &::after { + transform: scale(1.15) !important; + background-color: var(--primary-500); + } + } + + &::before { + content: ""; + display: inline-block; + position: absolute; + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid var(--grey-300); + transition: all 80ms var(--ease-in-out); + background-color: var(--white); + box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.05); + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-700); + background-color: transparent; + } + } + + &::after { + content: ""; + display: inline-block; + position: absolute; + width: 12px; + height: 12px; + border-radius: 50%; + background-color: var(--primary-700); + transition: all 150ms var(--ease-bounce); + transform: scale(0); + transform-origin: center; + box-shadow: var(--bevel); + + @media (prefers-color-scheme: dark) { + background-color: var(--primary-500); + } + } + } + + span { + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--grey-800); + user-select: none; + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + } +} diff --git a/client/src/framework/components/radio/radio.ts b/client/src/framework/components/radio/radio.ts new file mode 100644 index 0000000..10bb160 --- /dev/null +++ b/client/src/framework/components/radio/radio.ts @@ -0,0 +1,156 @@ +import { html, render } from "lit-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import soundscape from "~brixi/controllers/soundscape"; +import Component from "~brixi/component"; +import { UUID } from "@codewithkyle/uuid"; + +env.css("radio"); + +export interface IRadio { + label: string; + required: boolean; + name: string; + checked: boolean; + disabled: boolean; + value: string | number; +} +export default class Radio extends Component { + private inputId: string; + + constructor() { + super(); + this.inputId = UUID(); + this.model = { + label: "", + required: false, + name: "", + checked: false, + disabled: false, + value: null, + }; + } + + static get observedAttributes() { + return ["data-label", "data-required", "data-name", "data-checked", "data-disabled", "data-value"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + public getName(): string { + return this.model.name; + } + + public getValue(): string | number | null { + if (this.model.checked) { + return this.model.value; + } else { + return null; + } + } + + public reset(): void { + this.set({ + checked: false, + }); + } + + public clearError(): void { + if (this.state === "ERROR") { + this.trigger("RESET"); + } + } + + public setError(error: string): void { + if (error?.length) { + this.set({ + // @ts-ignore + error: error, + }); + this.trigger("ERROR"); + soundscape.play("error"); + } + } + + public validate(): boolean { + let isValid = true; + if (this.model.required && !this.model.checked) { + isValid = false; + this.setError("This field is required"); + } + return isValid; + } + + private handleChange: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const target = e.currentTarget as HTMLInputElement; + this.dispatchEvent( + new CustomEvent("change", { + detail: { + name: this.model.name, + value: target.value, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleKeydown: EventListener = (e: KeyboardEvent) => { + if (e.key === " ") { + this.classList.add("is-active"); + } + }; + + private handleKeyup: EventListener = (e: KeyboardEvent) => { + if (e.key === " ") { + this.classList.remove("is-active"); + const input = this.querySelector("input") as HTMLInputElement; + input.checked = !input.checked; + this.set({ checked: input.checked }); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + name: this.model.name, + value: this.model.value, + }, + bubbles: true, + cancelable: true, + }) + ); + } + }; + + render() { + this.setAttribute("state", this.state); + this.setAttribute("form-input", ""); + const view = html` +
    + + +
    + `; + render(view, this); + } +} +env.bind("radio-component", Radio); diff --git a/client/src/framework/components/radio/readme.md b/client/src/framework/components/radio/readme.md new file mode 100644 index 0000000..ea4f5c0 --- /dev/null +++ b/client/src/framework/components/radio/readme.md @@ -0,0 +1,30 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| label | string | | +| requried | boolean | | +| checked | boolean | | +| disabled | boolean | | +| value | string or number | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Event Listeners + +The `change` event will fire as users interact with the radio inputs. + +```typescript +document.body.querySelector('radio-component').addEventListener('change', (e) => { + const { name, value } = e.detail; +}); +``` diff --git a/client/src/framework/components/radio/static.html b/client/src/framework/components/radio/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/select/index.html b/client/src/framework/components/select/index.html new file mode 100644 index 0000000..52c4162 --- /dev/null +++ b/client/src/framework/components/select/index.html @@ -0,0 +1,25 @@ + + + + + + + diff --git a/client/src/framework/components/select/readme.md b/client/src/framework/components/select/readme.md new file mode 100644 index 0000000..ac5952d --- /dev/null +++ b/client/src/framework/components/select/readme.md @@ -0,0 +1,48 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| label | string | | +| icon | string or HTMLElement | | +| instructions | string | | +| options | SelectOption[] | ✅ | +| required | boolean | | +| name | string | ✅ | +| value | string or number | | +| disabled | boolean | | +| autofocus | boolean | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Select Option + +```typescript +type SelectOption = { + label: string; + value: string | number; +} +``` + +### Event Listeners + +The `change` event will fire when the user changes the selected option. + +```typescript +document.body.querySelector('select-component').addEventListener('change', (e) => { + const { name, value } = e.detail; +}); +``` diff --git a/client/src/framework/components/select/select.scss b/client/src/framework/components/select/select.scss new file mode 100644 index 0000000..e0b0537 --- /dev/null +++ b/client/src/framework/components/select/select.scss @@ -0,0 +1,249 @@ +select-component, +.select { + display: inline-block; + width: 100%; + position: relative; + + &[state="DISABLED"] { + cursor: not-allowed !important; + opacity: 0.6; + + @media (prefers-color-scheme: dark) { + opacity: 0.3; + } + + label, + p { + color: var(--grey-400) !important; + + @media (prefers-color-scheme: dark) { + color: var(--grey-300) !important; + } + } + select-container { + background-color: var(--grey-50) !important; + border-color: var(--grey-200) !important; + box-shadow: none !important; + + &::after { + box-shadow: none !important; + } + + @media (prefers-color-scheme: dark) { + background-color: transparent !important; + border-color: var(--grey-700) !important; + } + + select { + cursor: not-allowed !important; + background-color: var(--grey-50) !important; + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--white-hsl) / 0.05) !important; + } + } + } + } + + &[state="ERROR"] { + p { + color: var(--danger-700) !important; + } + + select-container { + border-color: var(--danger-400) !important; + + .icon { + background-color: var(--danger-50) !important; + border-color: var(--danger-400) !important; + color: var(--danger-400) !important; + } + } + + @media (prefers-color-scheme: dark) { + p { + color: var(--danger-400) !important; + } + select-container { + &:focus-within { + box-shadow: 0 0 0 5px hsl(var(--danger-400-hsl) / 0.1) !important; + } + + .icon { + background-color: hsl(var(--danger-300-hsl) / 0.05) !important; + border-color: var(--danger-400) !important; + color: var(--danger-400) !important; + } + select { + background-color: hsl(var(--danger-300-hsl) / 0.05) !important; + } + .selector { + color: var(--danger-400) !important; + } + } + } + } + + label { + display: block; + width: 100%; + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--grey-700); + margin-bottom: 0.5rem; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + + select { + height: 36px; + display: block; + width: 100%; + flex: 1; + text-overflow: ellipsis; + overflow: hidden; + line-height: 36px; + padding: 0 calc(1rem + 36px) 0 0.5rem; + color: var(--grey-800); + transition: all 80ms var(--ease-in-out); + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + cursor: pointer; + + &::placeholder { + color: var(--grey-400); + } + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + background-color: hsl(var(--white-hsl) / 0.05); + + &:focus { + background-color: hsl(var(--white-hsl) / 0); + } + + &::placeholder { + color: var(--grey-500); + } + + * { + color: var(--grey-300); + background-color: var(--grey-800); + } + } + } + + p { + display: block; + margin-bottom: 0.5rem; + font-size: var(--font-xs); + color: var(--grey-500); + line-height: 1.375; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + + select-container { + display: flex; + flex-flow: row nowrap; + width: 100%; + border-radius: 0.375rem; + overflow: hidden; + border: var(--input-border); + background-color: var(--white); + box-shadow: var(--bevel); + transition: all 80ms var(--ease-in-out); + outline-offset: 0; + + &:focus-within { + outline: var(--focus-ring); + outline-offset: 5px; + transition: outline-offset 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + border-color: var(--primary-400); + outline: none; + box-shadow: 0 0 0 5px hsl(var(--primary-400-hsl) / 0.1); + + i { + border-color: var(--primary-400); + color: var(--primary-400); + } + } + } + + &::after { + content: ""; + display: inline-block; + position: absolute; + top: 0; + left: 0; + pointer-events: none; + box-shadow: inset 0 -1px 1px rgba(0, 0, 0, 0.025); + width: 100%; + height: 100%; + z-index: 3; + border-radius: 0.375rem; + } + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-700); + box-shadow: none; + background-color: transparent; + + &::after { + display: none; + } + } + } + + .selector { + width: 24px; + height: 24px; + display: inline-flex; + justify-content: center; + align-items: center; + color: var(--grey-400); + pointer-events: none; + position: absolute; + bottom: calc((36px - 24px) / 2); + right: 0.5rem; + transition: all 80ms var(--ease-in-out); + + svg { + width: 16px; + height: 16px; + } + } + + .icon { + display: inline-flex; + justify-content: center; + align-items: center; + width: 36px; + height: 36px; + pointer-events: none; + color: var(--grey-400); + background-color: var(--grey-50); + border-right: var(--input-border); + z-index: 2; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-700); + background-color: hsl(var(--white-hsl) / 0.05); + } + + svg { + width: 18px; + height: 18px; + } + } +} diff --git a/client/src/framework/components/select/select.ts b/client/src/framework/components/select/select.ts new file mode 100644 index 0000000..e697858 --- /dev/null +++ b/client/src/framework/components/select/select.ts @@ -0,0 +1,236 @@ +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import { cache } from "lit-html/directives/cache"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import soundscape from "~brixi/controllers/soundscape"; +import Component from "~brixi/component"; +import { UUID } from "@codewithkyle/uuid"; + +env.css("select"); + +export type SelectOption = { + label: string; + value: string; +}; + +export interface ISelect { + label: string; + icon: string | HTMLElement; + instructions: string; + options: Array; + required: boolean; + name: string; + error: string; + value: any; + disabled: boolean; + autofocus: boolean; +} +export default class Select extends Component { + private inputId: string; + constructor() { + super(); + this.inputId = UUID(); + this.state = this.dataset?.disabled ? "DISABLED" : "IDLING"; + this.stateMachine = { + IDLING: { + ERROR: "ERROR", + DISABLE: "DISABLED", + ENABLE: "IDLING", + }, + ERROR: { + RESET: "IDLING", + ERROR: "ERROR", + }, + DISABLED: { + ENABLE: "IDLING", + DISABLE: "DISABLED", + }, + }; + this.model = { + label: "", + name: "", + icon: "", + instructions: "", + options: [], + required: false, + error: null, + value: null, + disabled: false, + autofocus: false, + }; + } + + static get observedAttributes() { + return ["data-label", "data-icon", "data-instructions", "data-options", "data-required", "data-name", "data-value", "data-disabled", "data-autofocus"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + if (settings?.error) { + this.state = "ERROR"; + } + if (settings?.disabled) { + this.state = "DISABLED"; + } + if (settings?.autofocus) { + // @ts-ignore + document?.activeElement?.blur(); + } + this.set(settings); + } + + public renderCopy(): string | TemplateResult { + let output: string | TemplateResult; + if (this.state === "ERROR" && this.model.error?.length) { + output = html`

    ${this.model.error}

    `; + } else if (this.model.instructions?.length) { + output = html`

    ${unsafeHTML(this.model.instructions)}

    `; + } else { + output = ""; + } + return output; + } + + public renderIcon(): string | TemplateResult { + let output: string | TemplateResult; + if (this.model.icon instanceof HTMLElement) { + output = html` ${this.model.icon} `; + } else if (typeof this.model.icon === "string" && this.model.icon.length) { + output = html` ${unsafeHTML(this.model.icon)} `; + } else { + output = ""; + } + return output; + } + + public clearError() { + if (this.state === "ERROR") { + this.set({ error: null }); + this.trigger("RESET"); + } + } + + public reset(): void { + this.set({ + value: "", + }); + } + + public setError(error: string) { + this.set({ + error: error, + }); + this.trigger("ERROR"); + soundscape.play("error"); + } + + public validate(): boolean { + let isValid = true; + if (this.model.required && (this.model.value === "" || this.model.value == null)) { + isValid = false; + this.setError("This field is required."); + } else { + this.clearError(); + } + return isValid; + } + + private handleChange: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const target = e.currentTarget as HTMLSelectElement; + this.set({ + value: target.value, + }); + this.validate(); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + value: target.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + public getName(): string { + return this.model.name; + } + + public getValue(): any { + return this.model.value; + } + + public handleBlur: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + this.validate(); + this.dispatchEvent( + new CustomEvent("blur", { + detail: { + value: this.model.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private handleFocus: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + this.dispatchEvent( + new CustomEvent("focus", { + detail: { + value: this.model.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + public renderLabel(): string | TemplateResult { + return html``; + } + + render() { + if (this.state !== "DISABLED" && this.model.disabled) { + this.trigger("DISABLE"); + } else if (this.state === "DISABLED" && !this.model.disabled) { + this.trigger("ENABLE"); + } + this.setAttribute("state", this.state); + this.setAttribute("form-input", ""); + const view = html` + ${cache(this.model.label?.length ? this.renderLabel() : "")} ${this.renderCopy()} + + ${this.renderIcon()} + + + + + + + + `; + render(view, this); + } +} +env.bind("select-component", Select); diff --git a/client/src/framework/components/select/static.html b/client/src/framework/components/select/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/skeletons/index.html b/client/src/framework/components/skeletons/index.html new file mode 100644 index 0000000..9c05ede --- /dev/null +++ b/client/src/framework/components/skeletons/index.html @@ -0,0 +1,8 @@ +
    +
    +
    +
    +
    +
    +
    + diff --git a/client/src/framework/components/skeletons/readme.md b/client/src/framework/components/skeletons/readme.md new file mode 100644 index 0000000..a8ddbb0 --- /dev/null +++ b/client/src/framework/components/skeletons/readme.md @@ -0,0 +1,20 @@ +UI skeleton frames can be mocked using the `.skeleton` class. + +```html +
    +
    +
    +
    +
    +``` + +The skeleton class contains several add-on classes: + +- `-copy` +- `-heading` +- `-button` + - `-round` +- `-image` + - `-round` + +You can flip the colour scheme to use the "primary" site colour by adding the `-primary` class. diff --git a/client/src/framework/components/skeletons/skeletons.scss b/client/src/framework/components/skeletons/skeletons.scss new file mode 100644 index 0000000..fed21e9 --- /dev/null +++ b/client/src/framework/components/skeletons/skeletons.scss @@ -0,0 +1,77 @@ +.skeleton { + display: block; + position: relative; + border-radius: 0.25rem; + overflow: hidden; + background-color: hsl(var(--grey-500-hsl) / 0.05); + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--grey-400-hsl) / 0.1); + } + + &.-image { + &::before { + content: ""; + display: block; + width: 100%; + padding-bottom: 75%; + } + + &.-round { + border-radius: 50%; + + &::before { + padding-bottom: 100%; + } + } + } + + &.-heading { + height: 36px; + } + + &.-copy { + height: 24px; + } + + &.-button { + height: 36px; + width: 96px; + + &.-round { + width: 36px; + border-radius: 50%; + } + } + + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + background-color: hsl(var(--grey-500-hsl) / 0.15); + animation: skeletonPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none !important; + } + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--grey-400-hsl) / 0.15); + } + } +} +@keyframes skeletonPulse { + 0% { + opacity: 0; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + } +} diff --git a/client/src/framework/components/skeletons/static.html b/client/src/framework/components/skeletons/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/snackbar/index.html b/client/src/framework/components/snackbar/index.html new file mode 100644 index 0000000..9e1d36c --- /dev/null +++ b/client/src/framework/components/snackbar/index.html @@ -0,0 +1,11 @@ +
    + + +
    + + diff --git a/client/src/framework/components/snackbar/readme.md b/client/src/framework/components/snackbar/readme.md new file mode 100644 index 0000000..5e967d9 --- /dev/null +++ b/client/src/framework/components/snackbar/readme.md @@ -0,0 +1,6 @@ +Snackbar messages can be created by importing the static `notifications` class and calling the `snackbar()` function. + +```typescript +import notifications from "/js/alerts.js"; +notifications.snackbar("All snackbar notifications require a message."); +``` diff --git a/client/src/framework/components/snackbar/snackbar.scss b/client/src/framework/components/snackbar/snackbar.scss new file mode 100644 index 0000000..fe67e87 --- /dev/null +++ b/client/src/framework/components/snackbar/snackbar.scss @@ -0,0 +1,176 @@ +snackbar-component { + display: inline-flex; + align-items: center; + justify-content: space-between; + flex-flow: column wrap; + border-radius: 0.5rem; + background-color: var(--grey-950); + box-shadow: var(--shadow-black-md); + color: var(--white); + max-width: calc(100vw - 2rem); + width: 100vw; + opacity: 0; + z-index: 9000; + animation: notificationPop 300ms cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards; + left: 50%; + transform: scale(0.87) translateX(-50%); + transform-origin: left top; + position: fixed; + bottom: 1rem; + border: 1px solid var(--grey-900); + + @media (prefers-color-scheme: dark) { + backdrop-filter: blur(8px); + background-color: hsl(var(--grey-900-hsl) / 0.87); + border-color: var(--grey-800); + } + + @media (min-width: 411px) { + flex-flow: row nowrap; + } + + @media (min-width: 768px) { + width: auto; + } + + p { + padding: 1rem; + line-height: var(--line-snug); + font-size: var(--font-sm); + } + + snackbar-actions { + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-end; + padding: 0 1rem 1rem; + + @media (min-width: 411px) { + padding: 0; + padding-right: 0.5rem; + } + + button { + user-select: none; + font-weight: 500; + font-size: 0.875rem; + height: 36px; + line-height: 1; + padding: 0 0.5rem; + color: var(--primary-200); + text-transform: uppercase; + text-align: center; + cursor: pointer; + outline-offset: 0; + + &:not(:last-child) { + margin-right: 0.5rem; + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + transition: outline-offset 80ms var(--ease-in-out); + } + + &:hover { + &::before { + opacity: 0.05; + } + } + + &:active { + outline-offset: 0; + + &::before { + opacity: 0.1; + } + } + + &::before { + content: ""; + display: inline-block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 0.25rem; + background-color: var(--primary-400); + opacity: 0; + transition: all 80ms var(--ease-in-out); + } + } + + .close { + position: relative; + width: 36px; + height: 36px; + display: inline-flex; + justify-content: center; + padding: 0; + align-items: center; + flex-flow: column wrap; + color: var(--white); + cursor: pointer; + user-select: none; + transition: all 80ms var(--ease-in-out); + outline-offset: 0; + border-radius: 50%; + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + transition: outline-offset 80ms var(--ease-in-out); + } + + &:hover { + &::before { + opacity: 0.05; + } + } + + &:active { + outline-offset: 0; + + &::before { + opacity: 0.1; + } + } + + svg { + width: 18px; + height: 18px; + position: relative; + margin: 0; + } + + &::before { + width: 36px; + height: 36px; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: inline-block; + content: ""; + border-radius: 50%; + background-color: var(--white); + opacity: 0; + transition: all 80ms var(--ease-in-out); + } + } + } +} + +@keyframes notificationPop { + from { + opacity: 0; + transform: scale(0.87) translateX(-50%); + } + to { + opacity: 1; + transform: scale(1) translateX(-50%); + } +} diff --git a/client/src/framework/components/snackbar/static.html b/client/src/framework/components/snackbar/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/steps/index.html b/client/src/framework/components/steps/index.html new file mode 100644 index 0000000..d1b60c9 --- /dev/null +++ b/client/src/framework/components/steps/index.html @@ -0,0 +1,17 @@ + + + + + diff --git a/client/src/framework/components/steps/readme.md b/client/src/framework/components/steps/readme.md new file mode 100644 index 0000000..2a10209 --- /dev/null +++ b/client/src/framework/components/steps/readme.md @@ -0,0 +1,37 @@ +```html + +``` + +### Data Attributes + + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| steps | Step[] | ✅ | +| step | string | | +| layout | "horizontal" or "vertical" | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type Step = { + label: string; + name: string; + description?: string; +} +``` + +### Event Listeners + +The `step` event will fire when the user clicks to go back 1 or more steps. + +```typescript +document.body.querySelector('steps-component').addEventListener('step', (e) => { + console.error(e.detail.step); +}); +``` diff --git a/client/src/framework/components/steps/static.html b/client/src/framework/components/steps/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/steps/steps-horizontal.scss b/client/src/framework/components/steps/steps-horizontal.scss new file mode 100644 index 0000000..9b02c48 --- /dev/null +++ b/client/src/framework/components/steps/steps-horizontal.scss @@ -0,0 +1,112 @@ +steps-component { + &.horizontal { + overflow-x: auto; + white-space: nowrap; + display: grid; + gap: 2rem; + + button { + display: inline-flex; + align-items: flex-start; + justify-content: flex-start; + flex-flow: column wrap; + vertical-align: top; + text-align: left; + padding: 0.875rem 1rem calc(0.875rem + 0.25rem + 5px); + cursor: default; + white-space: normal; + min-width: 300px; + transition: all 80ms var(--ease-in-out); + overflow: hidden; + + &::after { + content: ""; + display: inline-block; + width: 100%; + height: 5px; + position: absolute; + bottom: 0; + left: 0; + transition: all 80ms var(--ease-in-out); + border-radius: 4px 4px 0 0; + transform: translateY(2px); + } + + &::before { + content: ""; + display: inline-block; + width: 100%; + height: calc(100% - 0.7rem); + position: absolute; + top: 0; + left: 0; + transition: all 80ms var(--ease-in-out); + border-radius: 0.5rem; + background-color: var(--grey-500); + opacity: 0; + } + + &[state="pending"] { + &::after { + background-color: var(--grey-300); + + @media (prefers-color-scheme: dark) { + background-color: var(--grey-700); + } + } + } + + &[state="active"] { + &::after { + background-color: var(--primary-500); + transform: translateY(0px); + + @media (prefers-color-scheme: dark) { + background-color: var(--primary-400); + } + } + &::before { + opacity: 0.05; + } + } + + &[state="completed"] { + cursor: pointer; + + &::after { + background-color: var(--primary-500); + + @media (prefers-color-scheme: dark) { + background-color: var(--primary-400); + } + } + + &:hover, + &:focus-visible { + &::before { + opacity: 0.05; + } + } + + &:active { + &::after { + transform: translateY(0); + } + &::before { + opacity: 0.05; + } + } + } + + h3 { + font-size: var(--font-sm); + } + + h3, + p, + h4 { + line-height: 1.618; + } + } + } +} diff --git a/client/src/framework/components/steps/steps-vertical.scss b/client/src/framework/components/steps/steps-vertical.scss new file mode 100644 index 0000000..ed15f49 --- /dev/null +++ b/client/src/framework/components/steps/steps-vertical.scss @@ -0,0 +1,237 @@ +steps-component { + &.vertical { + button { + display: flex; + align-items: center; + flex-flow: row nowrap; + cursor: default; + user-select: none; + + &:not(:last-of-type) { + padding-bottom: 2rem; + } + + &:last-of-type { + &::before { + display: none; + } + } + + &[state="pending"] { + h3 { + color: var(--grey-800); + } + + p { + color: var(--grey-700); + } + + @media (prefers-color-scheme: dark) { + h3 { + color: var(--grey-500); + } + + p { + color: var(--grey-500); + } + } + } + + &[state="active"] { + h3 { + color: var(--primary-700); + } + + p { + color: var(--grey-700); + } + + i { + border-color: var(--primary-600); + + &::before { + opacity: 1; + visibility: visible; + } + } + + @media (prefers-color-scheme: dark) { + h3 { + color: var(--primary-300); + } + + p { + color: var(--grey-300); + } + + i { + border-color: var(--primary-400); + } + } + } + + &[state="completed"] { + cursor: pointer; + + &::before { + background-color: var(--primary-600); + + @media (prefers-color-scheme: dark) { + background-color: var(--primary-400); + } + } + + h3 { + color: var(--grey-800); + } + + p { + color: var(--grey-700); + } + + i { + background-color: var(--primary-600); + border-color: var(--primary-600); + + svg { + opacity: 1; + visibility: visible; + } + } + + &:hover, + &:focus-visible { + .copy { + &::before { + background-color: var(--grey-50); + } + } + } + + &:active { + .copy { + &::before { + background-color: var(--grey-100); + } + } + } + + @media (prefers-color-scheme: dark) { + h3 { + color: var(--grey-500); + } + + p { + color: var(--grey-500); + } + + i { + background-color: var(--primary-400); + border-color: var(--primary-400); + } + + &:hover, + &:focus-visible { + .copy { + &::before { + background-color: hsl(var(--grey-400-hsl) / 0.05); + } + } + } + + &:active { + .copy { + &::before { + background-color: hsl(var(--grey-400-hsl) / 0.1); + } + } + } + } + } + + &::before { + content: ""; + display: inline-block; + width: 2px; + height: calc(100% - 43px); + top: 41px; + left: 18px; + position: absolute; + background-color: var(--grey-200); + border-radius: 1px; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + background-color: var(--grey-700); + } + } + + .copy { + display: flex; + flex-flow: column wrap; + align-items: flex-start; + justify-content: flex-end; + text-align: left; + width: 100%; + flex: 1; + position: relative; + + &::before { + content: ""; + transition: all 80ms var(--ease-in-out); + position: absolute; + top: -0.5rem; + left: -0.5rem; + width: calc(100% + 1rem); + height: calc(100% + 1rem); + border-radius: 0.5rem; + background-color: transparent; + } + } + + i { + width: 36px; + height: 36px; + border: 2px solid var(--grey-300); + display: inline-flex; + justify-content: center; + align-items: center; + color: var(--white); + margin-right: 1rem; + border-radius: 50%; + background-color: var(--white); + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + background-color: transparent; + border-color: var(--grey-700); + } + + svg { + width: 24px; + height: 24px; + opacity: 0; + visibility: hidden; + transition: all 80ms var(--ease-in-out); + } + + &::before { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + background-color: var(--primary-600); + border-radius: 50%; + position: absolute; + opacity: 0; + visibility: hidden; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + background-color: var(--primary-400); + } + } + } + } + } +} diff --git a/client/src/framework/components/steps/steps.scss b/client/src/framework/components/steps/steps.scss new file mode 100644 index 0000000..9714633 --- /dev/null +++ b/client/src/framework/components/steps/steps.scss @@ -0,0 +1,41 @@ +steps-component { + display: block; + width: 100%; + position: relative; + + h4 { + display: block; + line-height: 1.375; + font-weight: var(--font-medium); + color: var(--primary-700); + font-size: var(--font-sm); + text-transform: uppercase; + + @media (prefers-color-scheme: dark) { + color: var(--primary-300); + } + } + + h3 { + display: block; + line-height: 1.375; + font-weight: var(--font-medium); + text-transform: uppercase; + color: var(--grey-700); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + + p { + display: block; + line-height: 1.375; + font-size: var(--font-xs); + color: var(--grey-500); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } +} diff --git a/client/src/framework/components/steps/steps.ts b/client/src/framework/components/steps/steps.ts new file mode 100644 index 0000000..52e3f04 --- /dev/null +++ b/client/src/framework/components/steps/steps.ts @@ -0,0 +1,127 @@ +import { html, render } from "lit-html"; +import Component from "~brixi/component"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import { calcPercent } from "~brixi/utils/numpy"; + +env.css(["steps", "steps-vertical", "steps-horizontal"]); + +export interface Step { + label: string; + description?: string; + name: string; +} +export interface ISteps { + steps: Array; + activeStep: number; + step: string; + layout: "horizontal" | "vertical"; +} +export default class Steps extends Component { + constructor() { + super(); + this.model = { + steps: [], + activeStep: 0, + step: null, + layout: "vertical", + }; + } + + static get observedAttributes() { + return ["data-steps", "data-step", "data-layout"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + for (let i = 0; i < settings.steps.length; i++) { + if (settings.steps[i].name === settings?.step ?? null) { + settings.activeStep = i; + break; + } + } + this.set(settings); + } + + private handleClick: EventListener = (e: Event) => { + const target = e.currentTarget as HTMLElement; + const index = parseInt(target.dataset.index); + if (index < this.model.activeStep) { + this.set({ + activeStep: index, + }); + this.dispatchEvent( + new CustomEvent("step", { + detail: { + step: this.model.steps[index].name, + }, + bubbles: true, + cancelable: true, + }) + ); + } + }; + + private renderVerticalStep(step: Step, index: number) { + let state: string; + if (this.model.activeStep === index) { + state = "active"; + } else if (this.model.activeStep > index) { + state = "completed"; + } else { + state = "pending"; + } + return html` + + `; + } + + private renderHorizontalStep(step: Step, index: number) { + let state: string; + if (this.model.activeStep === index) { + state = "active"; + } else if (this.model.activeStep > index) { + state = "completed"; + } else { + state = "pending"; + } + return html` + + `; + } + + override render() { + const view = html` + ${this.model.steps.map((step, index) => { + switch (this.model.layout) { + case "horizontal": + return this.renderHorizontalStep(step, index); + case "vertical": + return this.renderVerticalStep(step, index); + default: + return ""; + } + })} + `; + this.classList.add(this.model.layout); + if (this.model.layout === "horizontal") { + this.style.gridTemplateColumns = `repeat(${this.model.steps.length}, minmax(300px, ${Math.floor(calcPercent(1, this.model.steps.length))}%))`; + } + render(view, this); + } +} +env.bind("steps-component", Steps); diff --git a/client/src/framework/components/tabs/index.html b/client/src/framework/components/tabs/index.html new file mode 100644 index 0000000..0a931d0 --- /dev/null +++ b/client/src/framework/components/tabs/index.html @@ -0,0 +1,26 @@ +
    + +
    +
    + +
    + + diff --git a/client/src/framework/components/tabs/readme.md b/client/src/framework/components/tabs/readme.md new file mode 100644 index 0000000..e461195 --- /dev/null +++ b/client/src/framework/components/tabs/readme.md @@ -0,0 +1,66 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| tabs | Tab[] | ✅ | +| sortable | boolean | | +| expandable | boolean | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +### Types + +```typescript +type Tab = { + label: string; + value: string | number; + icon?: string; + active?: boolean; +} +``` + +### HTML Content + +You can render HTML content in a tab icon by using the `encodeURI()` function. [Learn more about URI encoding on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI). + +```javascript +html` + ')}"}]' + > +` +``` + +### Event Listeners + +The `sort` event will fire after the user changes the tab sort order. + +```typescript +document.body.querySelector('tabs-component').addEventListener('sort', (e) => { + console.error(e.detail.values); +}); +``` + +The `add` event will fire after the user adds a new tab. + +```typescript +document.body.querySelector('tabs-component').addEventListener('add', (e) => { + const { label, value } = e.detail; +}); +``` + +The `change` event will fire when the user changes tabs. + +```typescript +document.body.querySelector('tabs-component').addEventListener('change', (e) => { + console.log(e.detail.value); +}); +``` diff --git a/client/src/framework/components/tabs/static.html b/client/src/framework/components/tabs/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/tabs/tabs.scss b/client/src/framework/components/tabs/tabs.scss new file mode 100644 index 0000000..2633dd1 --- /dev/null +++ b/client/src/framework/components/tabs/tabs.scss @@ -0,0 +1,145 @@ +tabs-component { + white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; + height: 40px; + display: inline-flex; + align-items: center; + flex-flow: row nowrap; + + tabs-container { + white-space: nowrap; + height: 100%; + display: inline-flex; + flex-flow: row nowrap; + align-items: end; + } + + tab-component { + height: 100%; + display: inline-flex; + align-items: start; + justify-content: center; + flex-flow: row nowrap; + position: relative; + cursor: pointer; + outline-offset: 0; + + &:not(:last-child) { + margin-right: 0.5rem; + } + + &:hover { + span { + &::before { + opacity: 0.05; + } + } + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + transition: outline-offset 80ms var(--ease-in-out); + } + + &.is-active { + color: var(--primary-900); + + &::after { + transform: translate(0, 0); + } + + span { + &::before { + opacity: 0.05; + } + } + } + + &.has-icon { + span { + padding: 0 0.5rem 0 0.75rem; + } + } + + &:active { + span { + &::before { + opacity: 0.1; + } + } + } + + span { + display: inline-flex; + align-items: center; + justify-content: center; + flex-flow: row nowrap; + white-space: nowrap; + color: var(--grey-700); + font-size: var(--font-sm); + line-height: 1; + height: 32px; + padding: 0 0.75rem; + user-select: none; + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + + &::before { + transition: all 80ms var(--ease-in-out); + content: ""; + display: inline-block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 0.5rem; + opacity: 0; + background-color: var(--grey-500); + + @media (prefers-color-scheme: dark) { + background-color: var(--grey-400); + } + } + + i { + margin-right: 0.5rem; + display: inline-flex; + justify-content: center; + align-items: center; + color: var(--grey-400); + + @media (prefers-color-scheme: dark) { + color: var(--grey-500); + } + + svg { + width: 16px; + height: 16px; + } + } + } + + &::after { + transition: all 80ms var(--ease-in-out); + content: ""; + display: inline-block; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 3px; + border-radius: 3px 3px 0 0; + transform: translate(0, 4px); + background-color: var(--primary-500); + + @media (prefers-color-scheme: dark) { + background-color: var(--primary-400); + } + } + } +} diff --git a/client/src/framework/components/tabs/tabs.ts b/client/src/framework/components/tabs/tabs.ts new file mode 100644 index 0000000..0eeeae6 --- /dev/null +++ b/client/src/framework/components/tabs/tabs.ts @@ -0,0 +1,246 @@ +import { html, render, TemplateResult } from "lit-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import Sortable from "sortablejs"; +import "~brixi/components/buttons/button/button"; +import { UUID } from "@codewithkyle/uuid"; +import Component from "~brixi/component"; + +env.css(["tabs", "button"]); + +export interface ITab { + label: string; + value: string | number; + icon?: string; + active?: boolean; + index?: number; +} +export interface ITabs { + tabs: Array; + active: number; + sortable: boolean; + expandable: boolean; + shrinkable: boolean; +} +export default class Tabs extends Component { + private firstRender: boolean; + + constructor() { + super(); + this.firstRender = true; + this.model = { + tabs: [], + active: 0, + sortable: false, + expandable: false, + shrinkable: false, + }; + } + + static get observedAttributes() { + return ["data-tabs", "data-active", "data-sortable", "data-expandable", "data-shrinkable"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + } + + /* + * Returns the rendered order of the tabs. + * Use the array of tab values to determine the order. + */ + public getOrder(): Array { + const values = []; + this.querySelectorAll("tab-component").forEach((tab: Tab) => { + values.push(tab.model.value); + }); + return values; + } + + private handleClick: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const { value, index } = (e as CustomEvent).detail; + this.callback(value, index); + }; + + public callback(value: string | number, index: number) { + this.set({ active: index }); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + value: value, + }, + bubbles: true, + cancelable: true, + }) + ); + } + + private sort() { + const tabsContainer = this.querySelector("tabs-container"); + Sortable.create(tabsContainer, { + animation: 150, + onUpdate: () => { + const values = this.getOrder(); + this.dispatchEvent( + new CustomEvent("sort", { + detail: { + values: values, + }, + bubbles: true, + cancelable: true, + }) + ); + }, + }); + tabsContainer.addEventListener("sort", (e) => { + e.stopImmediatePropagation(); + }); + tabsContainer.addEventListener("change", (e) => { + e.stopImmediatePropagation(); + }); + } + + private addTab() { + const label = window.prompt("New Tab Label"); + if (label != null && label.trim() !== "") { + const value = UUID(); + this.dispatchEvent( + new CustomEvent("add", { + detail: { + label: label.trim(), + value: value, + }, + bubbles: true, + cancelable: true, + }) + ); + const tab: ITab = { + label: label, + value: value, + }; + const updated = this.get(); + updated.tabs.push(tab); + this.set(updated); + //this.sort(); + this.callback(value, updated.tabs.length - 1); + //this.resetIndexes(); + } + } + + public resetIndexes() { + this.querySelectorAll("tab-component").forEach((tab: Tab, index) => { + tab.setAttribute("data-index", index.toString()); + }); + } + + private renderAddButton() { + let out: string | TemplateResult; + if (this.model.expandable) { + out = html` + + `; + } else { + out = ""; + } + return out; + } + + override render() { + const view = html` + + ${this.model.tabs.map((tab, index) => { + const isActive = index === this.model.active; + return html` + + `; + })} + + ${this.renderAddButton()} + `; + render(view, this); + if (this.model.sortable && this.firstRender) { + this.firstRender = false; + this.sort(); + } + } +} +class Tab extends Component { + constructor() { + super(); + this.model = { + label: "", + value: "", + icon: "", + active: false, + index: 0, + }; + this.render(); + } + + static get observedAttributes() { + return ["data-label", "data-value", "data-icon", "data-active", "data-index", "data-index"]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.set(settings); + this.addEventListener("click", this.handleClick); + } + + override disconnected() { + this.removeEventListener("click", this.handleClick); + } + + private handleClick = () => { + this.dispatchEvent( + new CustomEvent("tab", { + detail: { + value: this.model.value, + index: this.model.index, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private renderIcon() { + let out: string | TemplateResult; + if (this.model?.icon?.length) { + out = html` ${unsafeHTML(decodeURI(this.model.icon))} `; + } else { + out = ""; + } + return out; + } + + override render() { + const view = html`${this.renderIcon()} ${this.model.label}`; + this.tabIndex = 0; + this.setAttribute("sfx", "button"); + this.className = `${this.model.active ? "is-active" : ""} ${this.model?.icon ? "has-icon" : ""}`; + this.setAttribute("role", "button"); + this.setAttribute("aria-label", `Open ${this.model.label}`); + render(view, this); + } +} +env.bind("tab-component", Tab); +env.bind("tabs-component", Tabs); diff --git a/client/src/framework/components/textarea/index.html b/client/src/framework/components/textarea/index.html new file mode 100644 index 0000000..92c4b17 --- /dev/null +++ b/client/src/framework/components/textarea/index.html @@ -0,0 +1,46 @@ + + + + + + + + + diff --git a/client/src/framework/components/textarea/readme.md b/client/src/framework/components/textarea/readme.md new file mode 100644 index 0000000..d32bf10 --- /dev/null +++ b/client/src/framework/components/textarea/readme.md @@ -0,0 +1,59 @@ +```html + +``` + +### Data Attributes + +| Data Attribute | Type | Required | +| -------------- | ---- | -------- | +| name | string | ✅ | +| label | string | | +| instructions | string | | +| required | boolean | | +| autocomplete | string | | +| placeholder | string | | +| value | string | | +| maxlength | number | | +| minlength | number | | +| disabled | boolean | | +| readOnly | boolean | | +| autofocus | boolean | | +| rows | number | | + +Not sure what Data Attributes are? Learn about [Data Attributes on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*). + +Not sure what `autocomplete` values you can use? Learn about the [autocomplete attribute on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete). + +### Event Listeners + +The `input` event will fire while the user types. + +```typescript +document.body.querySelector('textarea-component').addEventListener('input', (e) => { + const { name, value } = e.detail; +}); +``` + +The `focus` event will fire when the user focuses the input. + +```typescript +document.body.querySelector('textarea-component').addEventListener('focus', (e) => { + const { name, value } = e.detail; +}); +``` + +The `blur` event will fire when the user blurs the input. + +```typescript +document.body.querySelector('textarea-component').addEventListener('blur', (e) => { + const { name, value } = e.detail; +}); +``` diff --git a/client/src/framework/components/textarea/static.html b/client/src/framework/components/textarea/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/textarea/textarea.scss b/client/src/framework/components/textarea/textarea.scss new file mode 100644 index 0000000..ad91fd2 --- /dev/null +++ b/client/src/framework/components/textarea/textarea.scss @@ -0,0 +1,155 @@ +textarea-component { + display: inline-block; + width: 100%; + position: relative; + + &[readonly] { + textarea { + padding-right: 2.5rem; + padding-bottom: 0.25rem; + } + } + + &[state="DISABLED"] { + cursor: not-allowed !important; + opacity: 0.6; + + @media (prefers-color-scheme: dark) { + opacity: 0.3; + } + + label, + p { + color: var(--grey-400) !important; + + @media (prefers-color-scheme: dark) { + color: var(--grey-300) !important; + } + } + textarea { + background-color: var(--grey-50) !important; + border-color: var(--grey-200) !important; + box-shadow: none !important; + cursor: not-allowed !important; + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-700) !important; + background-color: hsl(var(--white-hsl) / 0.05) !important; + } + } + } + + &[state="ERROR"] { + p { + color: var(--danger-700) !important; + } + + textarea { + border-color: var(--danger-400) !important; + } + + @media (prefers-color-scheme: dark) { + p { + color: var(--danger-400) !important; + } + textarea { + background-color: hsl(var(--danger-300-hsl) / 0.05) !important; + + &:focus { + box-shadow: 0 0 0 5px hsl(var(--danger-400-hsl) / 0.1) !important; + } + } + } + } + + label { + display: block; + width: 100%; + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--grey-700); + margin-bottom: 0.5rem; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } + + .counter { + position: absolute; + bottom: 0.25rem; + right: 0.75rem; + font-size: 10px; + color: var(--grey-400); + } + + textarea { + display: block; + width: 100%; + padding: 0.25rem 0.5rem 1rem; + color: var(--grey-800); + overflow-y: auto; + border: var(--input-border); + font-size: var(--font-sm); + line-height: 1.618; + border-radius: 0.375rem; + transition: border-color 80ms var(--ease-in-out), box-shadow 80ms var(--ease-in-out); + background-color: var(--white); + box-shadow: var(--bevel); + resize: vertical; + outline-offset: 0; + + &:only-child { + padding: 0 1rem; + } + + &:last-child { + padding-right: 1rem; + } + + &::placeholder { + color: var(--grey-400); + } + + &:focus { + outline: var(--focus-ring); + outline-offset: 5px; + transition: outline-offset 80ms var(--ease-in-out); + } + + @media (prefers-color-scheme: dark) { + border-color: var(--grey-700); + box-shadow: none; + background-color: transparent; + color: var(--grey-300); + background-color: hsl(var(--white-hsl) / 0.05); + + &::placeholder { + color: var(--grey-500); + } + + &:focus { + border-color: var(--primary-400); + outline: none; + box-shadow: 0 0 0 5px hsl(var(--primary-400-hsl) / 0.1); + } + + &:focus:not(:read-only) { + background-color: hsl(var(--white-hsl) / 0); + } + } + } + p { + display: block; + margin-bottom: 0.5rem; + font-size: var(--font-xs); + color: var(--grey-500); + line-height: 1.375; + transition: all 80ms var(--ease-in-out); + + @media (prefers-color-scheme: dark) { + color: var(--grey-300); + } + } +} diff --git a/client/src/framework/components/textarea/textarea.ts b/client/src/framework/components/textarea/textarea.ts new file mode 100644 index 0000000..182ad12 --- /dev/null +++ b/client/src/framework/components/textarea/textarea.ts @@ -0,0 +1,293 @@ +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import env from "~brixi/controllers/env"; +import { parseDataset } from "~brixi/utils/general"; +import soundscape from "~brixi/controllers/soundscape"; +import Component from "~brixi/component"; +import { UUID } from "@codewithkyle/uuid"; +import alerts from "~brixi/controllers/alerts"; + +env.css(["textarea", "button", "toast"]); + +export interface ITextarea { + label: string; + name: string; + instructions: string; + error: string; + required: boolean; + autocomplete: string; + placeholder: string; + value: string; + maxlength: number; + minlength: number; + disabled: boolean; + readOnly: boolean; + rows: number; + autofocus: boolean; +} +export default class Textarea extends Component { + private inputId: string; + + constructor() { + super(); + this.inputId = UUID(); + this.stateMachine = { + IDLING: { + ERROR: "ERROR", + DISABLE: "DISABLED", + }, + ERROR: { + RESET: "IDLING", + ERROR: "ERROR", + }, + DISABLED: { + ENABLE: "IDLING", + }, + }; + this.model = { + label: "", + instructions: null, + error: null, + name: "", + required: false, + autocomplete: "off", + placeholder: "", + value: "", + maxlength: Infinity, + minlength: 0, + disabled: false, + readOnly: false, + rows: 5, + autofocus: false, + }; + } + + static get observedAttributes() { + return [ + "data-label", + "data-name", + "data-instructions", + "data-required", + "data-autocomplete", + "data-placeholder", + "data-value", + "data-maxlength", + "data-minlength", + "data-disabled", + "data-read-only", + "data-rows", + "data-autofocus", + ]; + } + + override async connected() { + const settings = parseDataset(this.dataset, this.model); + this.state = settings?.disabled ? "DISABLED" : "IDLING"; + this.set(settings); + } + + public clearError() { + if (this.state === "ERROR") { + this.trigger("RESET"); + } + } + + public reset(): void { + this.set({ + // @ts-ignore + value: null, + }); + const input = this.querySelector("input") as HTMLInputElement; + if (input) { + input.value = null; + } + } + + public setError(error: string) { + this.set({ + error: error, + }); + this.trigger("ERROR"); + soundscape.play("error"); + } + + public validate(): boolean { + let isValid = true; + if (this.model.required && !this.model.value?.length) { + isValid = false; + this.setError("This field is required."); + } + if (this.model.required || (!this.model.required && this.model.value?.length)) { + if (this.model.minlength > this.model.value.length) { + isValid = false; + this.setError(`This input requires a least ${this.model.minlength} characters.`); + } else if (this.model.maxlength < this.model.value.length) { + isValid = false; + this.setError(`This input requires a least ${this.model.minlength} characters.`); + } + } + if (isValid) { + this.clearError(); + } + return isValid; + } + + public getName() { + return this.model.name; + } + + public getValue(): string { + return this.model.value; + } + + public handleBlur: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + this.validate(); + this.dispatchEvent( + new CustomEvent("blur", { + detail: { + value: this.model.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + public handleFocus: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + this.dispatchEvent( + new CustomEvent("focus", { + detail: { + value: this.model.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + public handleInput: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + const input = e.currentTarget as HTMLInputElement; + this.set({ + value: input.value, + }); + this.validate(); + this.dispatchEvent( + new CustomEvent("input", { + detail: { + value: input.value, + name: this.model.name, + }, + bubbles: true, + cancelable: true, + }) + ); + }; + + private noopEvent: EventListener = (e) => { + e.stopImmediatePropagation() + } + + private handleCopyClick: EventListener = (e: Event) => { + e.stopImmediatePropagation(); + window.navigator.clipboard.writeText(this.model.value).then(() => { + alerts.toast("Copied to clipboard"); + }); + }; + + public renderCopy(): string | TemplateResult { + let output: string | TemplateResult; + if (this.state === "IDLING" && this.model.instructions) { + output = html`

    ${unsafeHTML(this.model.instructions)}

    `; + } else if (this.state === "ERROR" && this.model.error) { + output = html`

    ${this.model.error}

    `; + } else { + output = ""; + } + return output; + } + + public renderLabel(): string | TemplateResult { + let output: string | TemplateResult; + if (this.model.label?.length) { + output = html``; + } else { + output = ""; + } + return output; + } + + private renderReadOnlyIcon(): string | TemplateResult { + let output: string | TemplateResult = ""; + if (this.model.readOnly) { + output = html` + + `; + } + return output; + } + + public renderCounter(): string | TemplateResult { + let out: string | TemplateResult; + if (this.model.maxlength === Infinity || this.model.readOnly) { + out = ""; + } else { + out = html` ${this.model.value?.length ?? 0}/${this.model.maxlength} `; + } + return out; + } + + render() { + this.setAttribute("state", this.state); + this.setAttribute("form-input", ""); + if (this.model.readOnly) { + this.setAttribute("readonly", ""); + } + const view = html` + ${this.renderLabel()} ${this.renderCopy()} + + ${this.renderCounter()} ${this.renderReadOnlyIcon()} + `; + render(view, this); + } +} +env.bind("textarea-component", Textarea); diff --git a/client/src/framework/components/toast/index.html b/client/src/framework/components/toast/index.html new file mode 100644 index 0000000..f45aeab --- /dev/null +++ b/client/src/framework/components/toast/index.html @@ -0,0 +1,12 @@ +
    + + +
    + + diff --git a/client/src/framework/components/toast/readme.md b/client/src/framework/components/toast/readme.md new file mode 100644 index 0000000..281628f --- /dev/null +++ b/client/src/framework/components/toast/readme.md @@ -0,0 +1,7 @@ +Toast messages can be displayed by importing the static `notifications` class then calling the `toast()` method. + +```typescript +import notifications from "~brixi/controllers/alerts"; + +notifications.toast(`Toast message example.`); +``` diff --git a/client/src/framework/components/toast/static.html b/client/src/framework/components/toast/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/toast/toast.scss b/client/src/framework/components/toast/toast.scss new file mode 100644 index 0000000..a96b7be --- /dev/null +++ b/client/src/framework/components/toast/toast.scss @@ -0,0 +1,58 @@ +toaster-component { + position: fixed; + z-index: 2000; + width: 300px; + bottom: 0; + left: 0; + padding: 0 0 1rem 1rem; + display: grid; + justify-content: start; + justify-items: center; + gap: 0.5rem; + pointer-events: none; +} +toaster-component output { + background-color: var(--grey-950); + box-shadow: var(--shadow-black-sm); + color: var(--white); + max-inline-size: min(25ch, 90vw); + padding: 0.75rem 1rem; + border-radius: 0.5rem; + animation: fade-in 0.3s ease, slide-in 0.3s ease, fade-out 0.3s 4.4s ease; + animation-fill-mode: forwards; + font-size: var(--font-sm); + line-height: var(--font-snug); + pointer-events: all; + transition: all 150ms var(--ease-in-out); + cursor: pointer; + border: 1px solid var(--grey-900); + + @media (prefers-color-scheme: dark) { + background-color: hsl(var(--grey-900-hsl) / 0.87); + border-color: var(--grey-800); + backdrop-filter: blur(8px); + } + + &:active { + transform: scale(0.95); + box-shadow: none; + opacity: 0.6; + } +} +@keyframes fade-in { + from { + opacity: 0; + } +} + +@keyframes fade-out { + to { + opacity: 0; + } +} + +@keyframes slide-in { + from { + transform: translateY(1rem); + } +} diff --git a/client/src/framework/components/tooltip/index.html b/client/src/framework/components/tooltip/index.html new file mode 100644 index 0000000..be6159b --- /dev/null +++ b/client/src/framework/components/tooltip/index.html @@ -0,0 +1 @@ +Example tooltip diff --git a/client/src/framework/components/tooltip/readme.md b/client/src/framework/components/tooltip/readme.md new file mode 100644 index 0000000..259c1b8 --- /dev/null +++ b/client/src/framework/components/tooltip/readme.md @@ -0,0 +1,14 @@ +Tooltips can be added to any HTML element by adding a `tooltip="Hello world"` attribute. + +If you need to support aria-labels (like on SVGs) you can set the `aria-label="A tiny duck"` attribute with a blank `tooltip` attribute. When the `aria-label` attribute is set its value will always be used. + +```html + + + +``` + diff --git a/client/src/framework/components/tooltip/static.html b/client/src/framework/components/tooltip/static.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/framework/components/tooltip/tooltip.scss b/client/src/framework/components/tooltip/tooltip.scss new file mode 100644 index 0000000..caf18b8 --- /dev/null +++ b/client/src/framework/components/tooltip/tooltip.scss @@ -0,0 +1,24 @@ +tool-tip { + background-color: var(--white); + color: var(--grey-800); + border-radius: 0.5rem; + line-height: 22px; + height: 24px; + white-space: nowrap; + padding: 0 0.5rem; + font-size: var(--font-xs); + font-weight: var(--font-medium); + box-shadow: var(--bevel); + pointer-events: none; + transform-origin: center; + display: inline-block; + user-select: none; + border: 1px solid var(--grey-300); + + @media (prefers-color-scheme: dark) { + backdrop-filter: blur(4px); + background-color: hsl(var(--grey-900-hsl) / 0.87); + color: var(--white); + border: 1px solid var(--grey-800); + } +} diff --git a/client/src/framework/controllers/alerts.ts b/client/src/framework/controllers/alerts.ts new file mode 100644 index 0000000..9a3fa33 --- /dev/null +++ b/client/src/framework/controllers/alerts.ts @@ -0,0 +1,128 @@ +import snackbar from "@codewithkyle/notifyjs/dist/snackbar"; +import notifications from "@codewithkyle/notifyjs/dist/notifications"; +import toaster from "@codewithkyle/notifyjs/dist/toaster"; +import type { NotificationButton } from "@codewithkyle/notifyjs"; +import sound from "./soundscape"; + +class Alerts { + public snackbar(message: string, buttons: Array = []) { + snackbar({ + duration: 10, + closeable: true, + message: message, + buttons: buttons, + }); + sound.play("snackbar"); + } + + /** + * Notify a user that something has happened. + */ + public alert( + title: string, + message: string, + actions: Array<{ + label: string; + callback: Function; + }> = [], + duration: number = Infinity + ) { + notifications.push({ + title: title, + message: message, + icon: ``, + duration: duration, + closeable: true, + buttons: actions, + }); + sound.play("notification"); + } + + /** + * Notify a user that an action they triggered has succeeded. + */ + public success( + title: string, + message: string, + actions: Array<{ + label: string; + callback: Function; + }> = [], + duration: number = Infinity + ) { + notifications.push({ + title: title, + message: message, + icon: ``, + classes: ["-green"], + duration: duration, + closeable: true, + buttons: actions, + }); + sound.play("success"); + } + + /** + * Warn the user of something. + */ + public warn( + title: string, + message: string, + actions: Array<{ + label: string; + callback: Function; + }> = [], + duration: number = Infinity + ) { + notifications.push({ + title: title, + message: message, + icon: ``, + classes: ["-yellow"], + duration: duration, + closeable: true, + buttons: actions, + }); + sound.play("warning"); + } + + /** + * Notify the user that an action they triggered has failed. + */ + public error( + title: string, + message: string, + actions: Array<{ + label: string; + callback: Function; + }> = [], + duration: number = Infinity + ) { + notifications.push({ + title: title, + message: message, + icon: ``, + classes: ["-red"], + duration: duration, + closeable: true, + buttons: actions, + }); + sound.play("error-alert"); + } + + /** + * Add a custom toast element to the toaster. + */ + public append(toast: HTMLElement) { + notifications.append(toast); + } + + public toast(message: string, duration = 5) { + toaster.push({ + message: message, + duration: duration, + }); + } +} +const alerts = new Alerts(); +export default alerts; diff --git a/client/src/framework/controllers/api.ts b/client/src/framework/controllers/api.ts new file mode 100644 index 0000000..0c123f1 --- /dev/null +++ b/client/src/framework/controllers/api.ts @@ -0,0 +1,187 @@ +export interface Request { + route: string; + method?: Method; + origin?: string; + body?: BodyParams; + headers?: Headers; + params?: GetParams; + output?: "JSON" | "Blob" | "Text"; +} +export interface Response { + title: string | null; + message: string | null; + status: number; + code: string; + data: any; + success: boolean; +} +export type Headers = { + [header: string]: string; +}; +export type GetParams = { + [param: string]: string | number | string[] | number[]; +}; +export type BodyParams = { + [param: string]: any; +}; +export type Method = "GET" | "POST" | "PUT" | "PATCH" | "PURGE" | "DELETE" | "HEAD"; + +class API { + private defaultHeaders: Headers; + private defaultParams: GetParams; + private defaultBody: BodyParams; + private url: string; + + constructor() { + this.defaultHeaders = {}; + this.defaultBody = {}; + this.defaultParams = {}; + this.setURL(location.origin); + } + + public setURL(url: string): void { + this.url = url.replace(/\/$/, "").trim(); + } + + public setHeaders(headers: Headers): void { + this.defaultHeaders = headers; + } + + public setBody(body: BodyParams): void { + this.defaultBody = body; + } + + public setGetParams(params: GetParams): void { + this.defaultParams = params; + } + + /** + * Perform a fetch request. + * @example const response = await api.fetch({ method: "POST", route: "/v1/user", body: { name: "Jon Smith" } }); + */ + public async fetch(request: Request) { + let out: Response = { + title: null, + message: null, + status: 200, + code: "0x000", + data: null, + success: true, + }; + try { + if (request?.origin) { + request.origin = request.origin.replace(/\/$/, "").trim(); + } else { + request.origin = this.url; + } + let url = `${request.origin}/${request.route.replace(/.*?\//, "").replace(/\?.*/, "").trim()}`; + url = this.attachGetParams(url, request); + const options: RequestInit = this.buildRequestOptions(request); + const body = this.buildBody(request); + if (body !== null) { + options.body = body; + } + const fetchRequest = await fetch(url, options); + let response; + switch (request?.output) { + case "Blob": + response = await fetchRequest.blob(); + response = URL.createObjectURL(response); + break; + case "Text": + response = await fetchRequest.text(); + break; + default: + response = await fetchRequest.json(); + break; + } + if (fetchRequest.ok) { + out.success = true; + if (typeof response === "object") { + out = response; + } else { + out.data = response; + } + } else { + if (response?.title && response?.message) { + out = response; + } else { + out.title = "Server Error"; + out.message = `A ${fetchRequest.status} error occurred.`; + } + out.success = false; + } + } catch (e) { + console.error(e); + out.success = false; + out.title = "Network Error"; + out.message = "Failed to connect with the API. Check your network connection and try again."; + out.status = 418; + out.code = "1x418"; + } + // @ts-ignore + return out as T; + } + + private buildBody(request: Request): string | null { + if (request?.body) { + if (typeof request.body === "object") { + request.body = Object.assign(this.defaultBody, request.body); + } else { + request.body = this.defaultBody; + console.warn("Invalid request body. Body must be an object."); + } + } else { + request.body = this.defaultBody; + } + if (Object.keys(request.body).length) { + return JSON.stringify(request.body); + } else { + return null; + } + } + + private buildRequestOptions(request: Request): RequestInit { + if (request?.headers) { + if (typeof request.headers === "object") { + request.headers = Object.assign(this.defaultHeaders, request.headers); + } else { + request.headers = this.defaultHeaders; + console.warn("Invalid request headers. Headers must be an object."); + } + } else { + request.headers = this.defaultHeaders; + } + return { + method: request?.method ?? "GET", + headers: new Headers(request.headers), + }; + } + + private attachGetParams(url: string, request: Request): string { + if (request?.params) { + if (typeof request.params === "object") { + request.params = Object.assign(this.defaultParams, request.params); + } else { + request.params = this.defaultParams; + console.warn("Invalid request params. Params must be an object."); + } + } else { + request.params = this.defaultParams; + } + url += "?"; + for (const param in request.params) { + const value = request.params[param]; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + url += `${param}=${value[i]}&`; + } + } else { + url += `${param}=${request.params[param]}&`; + } + } + return url.replace(/\&$/, "").trim(); + } +} +const api = new API(); +export default api; diff --git a/client/src/framework/controllers/env.ts b/client/src/framework/controllers/env.ts new file mode 100644 index 0000000..cd1dc2f --- /dev/null +++ b/client/src/framework/controllers/env.ts @@ -0,0 +1,246 @@ +import { UUID } from "@codewithkyle/uuid"; + +export type NetworkType = "4g" | "3g" | "2g" | "slow-2g"; + +export type DOMState = "loading" | "idling" | "booting"; + +export type Browser = "chrome" | "safari" | "edge" | "chromium-edge" | "ie" | "firefox" | "unknown" | "opera"; + +class Environment { + public connection: NetworkType; + public cpu: number; + public memory: number | null; + public domState: DOMState; + public dataSaver: boolean; + public browser: Browser; + private tickets: Array; + + constructor() { + this.memory = 4; + this.cpu = window.navigator?.hardwareConcurrency || 2; + this.connection = "4g"; + this.domState = "booting"; + this.dataSaver = false; + this.browser = "unknown"; + this.tickets = []; + } + + public boot() { + this.setBrowser(); + + if ("connection" in navigator) { + // @ts-ignore + this.connection = window.navigator.connection.effectiveType; + // @ts-ignore + this.dataSaver = window.navigator.connection.saveData; + // @ts-ignore + navigator.connection.onchange = this.handleNetworkChange.bind(this); + } + + if ("deviceMemory" in navigator) { + // @ts-ignore + this.memory = window.navigator.deviceMemory; + } + + if (this.tickets.length) { + this.setDOMState("loading"); + } else { + this.setDOMState("idling"); + } + } + + private handleNetworkChange: EventListener = () => { + // @ts-ignore + this.connection = window.navigator.connection.effectiveType; + sessionStorage.removeItem("connection-choice"); + }; + + /** + * Attempts to set the DOM to the `idling` state. The DOM will only idle when all `startLoading()` methods have been resolved. + * @param ticket - the `string` the was provided by the `startLoading()` method. + */ + public stopLoading(ticket: string): void { + if (!ticket || typeof ticket !== "string") { + console.error(`A ticket with the typeof 'string' is required to end the loading state.`); + return; + } + + const index = this.tickets.indexOf(ticket); + if (index !== -1) { + this.tickets.splice(index); + } + + if (this.tickets.length === 0 && this.domState === "loading") { + this.setDOMState("idling"); + } + } + + /** + * Sets the DOM to the `soft-loading` state. + * @returns a ticket `string` that is required to stop the loading state. + */ + public startLoading(): string { + if (this.domState === "idling") { + this.setDOMState("loading"); + } + const ticket = UUID(); + this.tickets.push(ticket); + return ticket; + } + + /** + * Sets the DOMs state attribute. + * DO NOT USE THIS METHOD. DO NOT MANUALLY SET THE DOMs STATE. + * @param newState - the new state of the document element + */ + private setDOMState(newState: DOMState): void { + this.domState = newState; + if (this.domState !== "loading") { + this.tickets = []; + } + document.documentElement.setAttribute("state", this.domState); + } + + /** + * Checks if the provided connection is greater than or equal to the current conneciton. + * @param requiredConnection - network connection string + */ + public checkConnection(requiredConnection): boolean { + let passed = false; + switch (requiredConnection) { + case "4g": + if (this.connection !== "2g" && this.connection !== "slow-2g" && this.connection !== "3g") { + passed = true; + } + break; + case "3g": + if (this.connection !== "2g" && this.connection !== "slow-2g") { + passed = true; + } + break; + case "2g": + if (this.connection !== "slow-2g") { + passed = true; + } + break; + case "slow-2g": + passed = true; + break; + default: + passed = true; + break; + } + return passed; + } + + private setBrowser() { + // @ts-ignore + const isOpera = (!!window.opr && !!opr.addons) || !!window.opera || navigator.userAgent.indexOf(" OPR/") >= 0; + + // @ts-ignore + const isFirefox = typeof InstallTrigger !== "undefined"; + + const isSafari = + // @ts-ignore + /constructor/i.test(window.HTMLElement) || + (function (p) { + return p.toString() === "[object SafariRemoteNotification]"; + // @ts-ignore + })(!window["safari"] || (typeof safari !== "undefined" && safari.pushNotification)); + + // @ts-ignore + const isIE = /*@cc_on!@*/ false || !!document.documentMode; + + // @ts-ignore + const isEdge = !isIE && !!window.StyleMedia; + + // @ts-ignore + const isChrome = !!window.chrome; + + const isEdgeChromium = isChrome && navigator.userAgent.indexOf("Edg") != -1; + + if (isOpera) { + this.browser = "opera"; + } else if (isFirefox) { + this.browser = "firefox"; + } else if (isSafari) { + this.browser = "safari"; + } else if (isIE) { + this.browser = "ie"; + } else if (isEdge) { + this.browser = "edge"; + } else if (isChrome) { + this.browser = "chrome"; + } else if (isEdgeChromium) { + this.browser = "chromium-edge"; + } else { + this.browser = "unknown"; + } + document.documentElement.setAttribute("browser", this.browser); + } + + /** + * Binds the custom element to the class. + * @deprecated use `bind()` instead. + */ + public mount(tagName: string, constructor: CustomElementConstructor) { + this.bind(tagName, constructor); + } + /** + * Registers a Web Component by binding the Custom Element's tag name to the provided class. + */ + public bind(tagName: string, constructor: CustomElementConstructor) { + if (!customElements.get(tagName)) { + customElements.define(tagName, constructor); + } + } + public css(files: string | string[]): Promise { + return new Promise(async (resolve) => { + if (!Array.isArray(files)) { + files = [files]; + } + if (!files.length) { + resolve(); + } + let resolved = 0; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + let href: string; + if (file.indexOf("https://") === 0 || file.indexOf("http://") === 0) { + href = file; + } else if (file.indexOf("./") === 0 || file.indexOf("../") === 0 || file.indexOf("/") === 0) { + href = file; + } else { + href = `${location.origin}/css/${file.replace(/\.css$/g, "").trim()}.css`; + } + let stylesheet: HTMLLinkElement = document.head.querySelector(`link[href="${href}"]`); + if (!stylesheet) { + new Promise((resolve) => { + stylesheet = document.createElement("link"); + stylesheet.href = href; + stylesheet.rel = "stylesheet"; + stylesheet.onload = () => { + resolve(); + }; + stylesheet.onerror = () => { + resolve(); + }; + document.head.appendChild(stylesheet); + }).then(() => { + resolved++; + if (resolved === files.length) { + resolve(); + } + }); + } else { + resolved++; + if (resolved === files.length) { + resolve(); + } + } + } + }); + } +} +const env = new Environment(); +export { env as default }; diff --git a/client/src/framework/controllers/modals.ts b/client/src/framework/controllers/modals.ts new file mode 100644 index 0000000..a4fb725 --- /dev/null +++ b/client/src/framework/controllers/modals.ts @@ -0,0 +1,317 @@ +import { html, render, TemplateResult } from "lit-html"; +import { unsafeHTML } from "lit-html/directives/unsafe-html"; +import "~brixi/components/buttons/button/button"; +import "~brixi/components/buttons/submit-button/submit-button"; +import "~brixi/components/form/form"; +import { noop } from "~brixi/utils/general"; +import env from "./env"; + +interface DangerousSettings { + title: string; + message: string; + confirm?: string; + cancel?: string; + width?: number; + callbacks?: { + cancel?: () => void; + confirm?: () => void; + }; +} + +interface ConfirmSettings { + title: string; + message: string; + confirm?: string; + cancel?: string; + width?: number; + callbacks?: { + cancel?: () => void; + confirm?: () => void; + }; +} + +interface PassiveSettings { + title: string; + message: string; + width?: number; + actions?: Array<{ + label: string; + callback: () => void; + }>; +} + +interface FormSettings { + title?: string; + message?: string; + width?: number; + view: TemplateResult; + callbacks?: { + submit?: (data: { [key: string]: any }, form: HTMLElement, modal: HTMLElement) => void; + cancel?: () => void; + }; + cancel?: string; + submit?: string; +} + +interface RawSettings { + view: TemplateResult | HTMLElement; + width?: number; +} + +class ModalMaker { + public raw(settings: RawSettings): ModalComponent { + const data = Object.assign( + { + view: html``, + width: 512, + }, + settings + ); + const el = new ModalComponent(data.view, data.width, "raw"); + document.body.appendChild(el); + return el; + } + + public form(settings: FormSettings) { + const data = Object.assign( + { + title: "", + message: "", + view: html``, + width: 512, + callbacks: { + onSubmit: noop, + onCancel: noop, + }, + cancel: "Cancel", + submit: "Submit", + }, + settings + ); + let el: HTMLElement; + const view = html` + { + e.preventDefault(); + const form = e.currentTarget as HTMLElement; + // @ts-ignore + const valid = form.checkValidity(); + if (valid) { + // @ts-ignore + const formData = form.serialize(); + data.callbacks.submit(formData, form, el); + } + }} + > +
    + ${data.title?.length ? html`

    ${data.title}

    ` : ""} ${data.message?.length ? html`

    ${unsafeHTML(data.message)}

    ` : ""} ${data.view} +
    +
    +
    + { + console.log("cancel"); + if ("cancel" in data.callbacks && typeof data.callbacks.cancel === "function") { + data.callbacks.cancel(); + } + el.remove(); + }} + > + +
    +
    +
    + `; + el = new ModalComponent(view, data.width, "static-content"); + document.body.appendChild(el); + } + + public passive(settings: PassiveSettings) { + const data = Object.assign( + { + title: "", + message: "", + actions: [ + { + label: "Close", + callback: noop, + }, + ], + width: 512, + }, + settings + ); + let el: HTMLElement; + const view = html` +
    +

    ${data.title}

    +

    ${unsafeHTML(data.message)}

    +
    +
    +
    + ${data.actions.map( + (action) => html` + { + if (typeof action?.callback === "function") { + action.callback(); + } + el.remove(); + }} + class="ml-0.5" + > + ` + )} +
    +
    + `; + el = new ModalComponent(view, data.width, "static-content"); + document.body.appendChild(el); + } + + public confirm(settings: ConfirmSettings) { + const data = Object.assign( + { + title: "", + message: "", + confirm: "Submit", + cancel: "Cancel", + callbacks: { + cancel: noop, + confirm: noop, + }, + width: 512, + }, + settings + ); + let el: HTMLElement; + const view = html` +
    +

    ${data.title}

    +

    ${unsafeHTML(data.message)}

    +
    +
    +
    + { + if ("cancel" in data.callbacks && typeof data.callbacks.cancel === "function") { + data.callbacks.cancel(); + } + el.remove(); + }} + class="mr-0.5" + > + { + if ("confirm" in data.callbacks && typeof data.callbacks.confirm === "function") { + data.callbacks.confirm(); + } + el.remove(); + }} + > +
    +
    + `; + el = new ModalComponent(view, data.width, "static-content"); + document.body.appendChild(el); + } + + public dangerous(settings: DangerousSettings) { + const data = Object.assign( + { + title: "", + message: "", + confirm: "Delete", + cancel: "Cancel", + callbacks: { + cancel: noop, + confirm: noop, + }, + width: 512, + }, + settings + ); + let el: HTMLElement; + const view = html` +
    +

    ${data.title}

    +

    ${unsafeHTML(data.message)}

    +
    +
    +
    + { + if ("cancel" in data.callbacks && typeof data.callbacks.cancel === "function") { + data.callbacks.cancel(); + } + el.remove(); + }} + class="mr-0.5" + > + { + if ("confirm" in data.callbacks && typeof data.callbacks.confirm === "function") { + data.callbacks.confirm(); + } + el.remove(); + }} + > +
    +
    + `; + el = new ModalComponent(view, data.width, "static-content"); + document.body.appendChild(el); + } +} +const modals = new ModalMaker(); +export default modals; + +class ModalComponent extends HTMLElement { + private view: TemplateResult | HTMLElement; + private width: number; + + constructor(view: TemplateResult | HTMLElement, width: number, className: string) { + super(); + this.view = view; + this.width = width; + this.className = className; + env.css(["modals", "button"]).then(() => this.render()); + } + + private render() { + this.tabIndex = 0; + this.focus(); + const view = html` +
    + + `; + render(view, this); + } +} +env.bind("modal-component", ModalComponent); diff --git a/client/src/framework/controllers/pos.ts b/client/src/framework/controllers/pos.ts new file mode 100644 index 0000000..67bc59b --- /dev/null +++ b/client/src/framework/controllers/pos.ts @@ -0,0 +1,69 @@ +import { debounce } from "~brixi/utils/general"; + +class Positions { + public window: { + innerWidth: number; + innerHeight: number; + outterWidth: number; + outterHeight: number; + }; + + constructor() { + this.window = { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + outterWidth: window.outerWidth, + outterHeight: window.outerHeight, + }; + + window.addEventListener( + "resize", + () => { + debounce(this.doResize.bind(this), 300); + }, + { capture: true, passive: true } + ); + } + + private doResize = () => { + this.window = { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + outterWidth: window.outerWidth, + outterHeight: window.outerHeight, + }; + }; + + public positionElement(el: HTMLElement, x: number, y: number): void { + const bounds = el.getBoundingClientRect(); + if (x + bounds.width > this.window.innerWidth) { + x = this.window.innerWidth - bounds.width; + } else if (x < 0) { + x = 0; + } + if (y + bounds.height > this.window.innerHeight) { + y = this.window.innerHeight - bounds.height; + } else if (y < 0) { + y = 0; + } + el.style.transform = `translate(${x}px, ${y}px)`; + } + + public positionElementToElement(el: HTMLElement, target: HTMLElement, offset: number = 0): void { + const elBounds = el.getBoundingClientRect(); + const targetBounds = target.getBoundingClientRect(); + let top = targetBounds.top + targetBounds.height + offset; + if (top + elBounds.height >= this.window.innerHeight) { + top = targetBounds.top - elBounds.height - offset; + } + let left = targetBounds.right - elBounds.width; + if (left + elBounds.width >= this.window.innerWidth) { + left = this.window.innerWidth - elBounds.width; + } else if (left < 0) { + left = 0; + } + el.style.transform = `translate(${left}px, ${top}px)`; + } +} +const pos = new Positions(); +export default pos; diff --git a/client/src/framework/controllers/soundscape.ts b/client/src/framework/controllers/soundscape.ts new file mode 100644 index 0000000..34d3a3d --- /dev/null +++ b/client/src/framework/controllers/soundscape.ts @@ -0,0 +1,236 @@ +interface ISound { + ctx: AudioContext; + gain: GainNode; + buffer: AudioBuffer; +} + +/** + * @see https://material.io/design/sound/sound-resources.html + * @license CC-BY-4.0 + */ +class Soundscape { + private sounds: { + [handle: string]: ISound; + }; + private soundState: { + [handle: string]: { + isEnable: number; + volume: number; + }; + }; + + private hasTouched: boolean; + private hasPointer: boolean; + + constructor() { + this.hasTouched = false; + this.hasPointer = false; + + this.sounds = {}; + this.soundState = JSON.parse(localStorage.getItem("sfx") || "{}"); + + this.addButtonListeners(); + } + + private addButtonListeners() { + window.addEventListener("mousemove", this.mousemove, { capture: true, passive: true }); + // Button events + window.addEventListener("mouseenter", this.mouseover, { capture: true, passive: true }); + window.addEventListener("mouseleave", this.mouseleave, { capture: true, passive: true }); + window.addEventListener("focus", this.focus, { + capture: true, + passive: true, + }); + window.addEventListener("blur", this.mouseleave, { + capture: true, + passive: true, + }); + window.addEventListener("mousedown", this.click, { + capture: true, + passive: true, + }); + window.addEventListener("touchstart", this.click, { + capture: true, + passive: true, + }); + window.addEventListener("keydown", this.click, { + capture: true, + passive: true, + }); + } + + private mousemove: EventListener = () => { + this.hasPointer = true; + window.removeEventListener("mousemove", this.mousemove); + }; + + private mouseleave: EventListener = (e: Event) => { + const target = e.target as HTMLElement; + if (target instanceof HTMLElement && target.getAttribute("sfx") === "button") { + target.dataset.isMouseOver = "0"; + } + }; + + private mouseover: EventListener = (e: Event) => { + const target = e.target as any; + if (e instanceof MouseEvent && target.getAttribute("sfx") === "button" && target.dataset.isMouseOver !== "1" && !target?.disabled) { + target.dataset.isMouseOver = "1"; + this.play("hover"); + } + }; + + private focus: EventListener = (e: Event) => { + if (this.hasPointer || this.hasTouched) { + return; + } + const target = e.target as any; + if (target.getAttribute("sfx") === "button" && !target?.disabled) { + if (target.dataset.isMouseOver === "0" || !target.dataset.isMouseOver) { + this.play("hover"); + } + } + }; + + private click: EventListener = (e: Event | TouchEvent) => { + if (window.TouchEvent && e instanceof TouchEvent) { + this.hasTouched = true; + } + const target = e.target as HTMLElement; + let validKey = false; + if (e instanceof KeyboardEvent) { + const key = e.key.toLowerCase(); + if (key === " ") { + validKey = true; + } + } + if (target instanceof HTMLElement && (target.getAttribute("sfx") === "button" || target.closest(`[sfx="button"]`) !== null)) { + if (validKey || !(e instanceof KeyboardEvent)) { + this.play("click"); + } + } + }; + + private save(): void { + localStorage.setItem("sfx", JSON.stringify(this.soundState)); + } + + public toggleSound(handle: string, isEnable: boolean): void { + if (handle in this.sounds) { + this.soundState[handle].isEnable = isEnable ? 1 : 0; + if (isEnable) { + this.sounds[handle].ctx.resume(); + } else { + this.sounds[handle].ctx.suspend(); + } + this.save(); + } + } + + /** + * Creates a new sound source. + * Returns `null` if the sound does not exist OR if playback has been disabled. + **/ + public play(handle: string, loop: boolean = false): AudioBufferSourceNode | null { + if (!(handle in this.sounds)) return null; + const source = this.sounds[handle].ctx.createBufferSource(); + source.buffer = this.sounds[handle].buffer; + source.connect(this.sounds[handle].gain); + source.loop = loop; + source.start(0); + if (!this.soundState?.[handle]?.isEnable) { + this.sounds[handle].ctx.suspend(); + return null; + } + return source; + } + + /** + * Pauses a sound source. + */ + public pause(handle: string): void { + if (!(handle in this.sounds) || !this.soundState?.[handle]?.isEnable) return; + this.sounds[handle].ctx.suspend(); + } + + /** + * Resumes a sound source. + */ + public resume(handle: string): void { + if (!(handle in this.sounds) || !this.soundState?.[handle]?.isEnable) return; + this.sounds[handle].ctx.resume(); + } + + public setVolume(handle: string, volume: number): void { + if (!(handle in this.sounds)) return; + this.sounds[handle].gain.gain.value = volume; + this.soundState[handle].volume = volume; + this.save(); + } + + public getVolume(handle: string): number { + if (!(handle in this.sounds)) return 0; + return this.soundState[handle].volume; + } + + public async add(handle: string, src: string, force: boolean = false): Promise { + if (this.sounds?.[handle] && !force) { + return this.sounds[handle]; + } + this.sounds[handle] = await this.createSound(src); + if (!(handle in this.soundState)) { + this.soundState[handle] = { + isEnable: 1, + volume: 1, + }; + } + this.toggleSound(handle, this.soundState[handle].isEnable === 1); + this.setVolume(handle, this.soundState[handle].volume); + return this.sounds[handle]; + } + + public get(handle: string): ISound | null { + return this.sounds?.[handle] ?? null; + } + + private async createSound(src: string): Promise { + try { + const req = await fetch(src); + const arrayBuffer = await req.arrayBuffer(); + const sound: ISound = { + ctx: new AudioContext(), + gain: null, + buffer: null, + }; + sound.gain = sound.ctx.createGain(); + sound.gain.connect(sound.ctx.destination); + sound.buffer = await sound.ctx.decodeAudioData(arrayBuffer); + return sound; + } catch (e) { + console.error(e); + return { + ctx: null, + gain: null, + buffer: null, + }; + } + } + + public async load(): Promise { + const promises = [ + this.add("hover", "/audio/mouseover.wav", true), + this.add("click", "/audio/mouseclick.wav", true), + this.add("error", "/audio/error.wav", true), + this.add("error-alert", "/audio/error-alert.wav", true), + this.add("warning", "/audio/warning.wav", true), + this.add("notification", "/audio/notification.wav", true), + this.add("success", "/audio/success.wav", true), + this.add("snackbar", "/audio/snackbar.wav", true), + this.add("activate", "/audio/activate.wav", true), + this.add("deactivate", "/audio/deactivate.wav", true), + this.add("camera", "/audio/camera.wav", true), + ]; + await Promise.all(promises); + } +} +const sound = new Soundscape(); +export { sound as default }; diff --git a/client/src/framework/stylesheets/framework-core.scss b/client/src/framework/stylesheets/framework-core.scss new file mode 100644 index 0000000..8783c78 --- /dev/null +++ b/client/src/framework/stylesheets/framework-core.scss @@ -0,0 +1,82 @@ +@import url("https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"); + +/* + 1. Use a more-intuitive box-sizing model. +*/ +*, +*::before, +*::after { + box-sizing: border-box; +} +/* + 2. Remove default margin +*/ +* { + margin: 0; +} +/* + 3. Allow percentage-based heights in the application +*/ +html, +body { + height: 100%; +} +/* + Typographic tweaks! + 4. Add default line-height + 5. Improve text rendering +*/ +body { + line-height: 1; + -webkit-font-smoothing: antialiased; +} +/* + 6. Improve media defaults +*/ +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} +/* + 7. Remove built-in form typography styles +*/ +input, +button, +textarea, +select { + font: inherit; + background: transparent; +} +/* + 8. Avoid text overflows +*/ +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} +/* + 9. Create a root stacking context +*/ +#root, +#__next { + isolation: isolate; +} + +button { + text-align: left; + padding: 0; +} + +body { + font-family: Rubik, Roboto, "Open Sans", Ubuntu, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Cantarell, "Helvetica Neue", sans-serif; + color: var(--grey-700); +} diff --git a/client/src/framework/types/accordion.d.ts b/client/src/framework/types/accordion.d.ts new file mode 100644 index 0000000..010e175 --- /dev/null +++ b/client/src/framework/types/accordion.d.ts @@ -0,0 +1,15 @@ +import Component from "~brixi/component"; +export interface AccordionSection { + label: string; + content: string; +} +export interface IAccordion { + sections: Array; +} +export default class Accordion extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): void; + private renderSection; + render(): void; +} diff --git a/client/src/framework/types/alert.d.ts b/client/src/framework/types/alert.d.ts new file mode 100644 index 0000000..5857bb9 --- /dev/null +++ b/client/src/framework/types/alert.d.ts @@ -0,0 +1,26 @@ +import "~brixi/components/buttons/button/button"; +import Component from "~brixi/component"; +export interface ActionItem { + label: string; + id: string; +} +export interface IAlert { + type: "warning" | "info" | "danger" | "success"; + heading: string; + description: string; + list: Array; + closeable: boolean; + actions: Array; +} +export default class Alert extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + private renderIcon; + private handleClose; + private handleActionClick; + private renderCloseButton; + private renderList; + private renderActions; + render(): void; +} diff --git a/client/src/framework/types/alerts.d.ts b/client/src/framework/types/alerts.d.ts new file mode 100644 index 0000000..b52efaa --- /dev/null +++ b/client/src/framework/types/alerts.d.ts @@ -0,0 +1,39 @@ +import type { NotificationButton } from "@codewithkyle/notifyjs"; +declare class Alerts { + snackbar(message: string, buttons?: Array): void; + /** + * Notify a user that something has happened. + */ + alert(title: string, message: string, actions?: Array<{ + label: string; + callback: Function; + }>, duration?: number): void; + /** + * Notify a user that an action they triggered has succeeded. + */ + success(title: string, message: string, actions?: Array<{ + label: string; + callback: Function; + }>, duration?: number): void; + /** + * Warn the user of something. + */ + warn(title: string, message: string, actions?: Array<{ + label: string; + callback: Function; + }>, duration?: number): void; + /** + * Notify the user that an action they triggered has failed. + */ + error(title: string, message: string, actions?: Array<{ + label: string; + callback: Function; + }>, duration?: number): void; + /** + * Add a custom toast element to the toaster. + */ + append(toast: HTMLElement): void; + toast(message: string, duration?: number): void; +} +declare const alerts: Alerts; +export default alerts; diff --git a/client/src/framework/types/api.d.ts b/client/src/framework/types/api.d.ts new file mode 100644 index 0000000..97603e7 --- /dev/null +++ b/client/src/framework/types/api.d.ts @@ -0,0 +1,48 @@ +export interface Request { + route: string; + method?: Method; + origin?: string; + body?: BodyParams; + headers?: Headers; + params?: GetParams; + output?: "JSON" | "Blob" | "Text"; +} +export interface Response { + title: string | null; + message: string | null; + status: number; + code: string; + data: any; + success: boolean; +} +export type Headers = { + [header: string]: string; +}; +export type GetParams = { + [param: string]: string | number | string[] | number[]; +}; +export type BodyParams = { + [param: string]: any; +}; +export type Method = "GET" | "POST" | "PUT" | "PATCH" | "PURGE" | "DELETE" | "HEAD"; +declare class API { + private defaultHeaders; + private defaultParams; + private defaultBody; + private url; + constructor(); + setURL(url: string): void; + setHeaders(headers: Headers): void; + setBody(body: BodyParams): void; + setGetParams(params: GetParams): void; + /** + * Perform a fetch request. + * @example const response = await api.fetch({ method: "POST", route: "/v1/user", body: { name: "Jon Smith" } }); + */ + fetch(request: Request): Promise; + private buildBody; + private buildRequestOptions; + private attachGetParams; +} +declare const api: API; +export default api; diff --git a/client/src/framework/types/assist-chip.d.ts b/client/src/framework/types/assist-chip.d.ts new file mode 100644 index 0000000..8c12084 --- /dev/null +++ b/client/src/framework/types/assist-chip.d.ts @@ -0,0 +1,14 @@ +import Component from "~brixi/component"; +export interface IAssistChip { + label: string; + icon: string; +} +export default class AssistChip extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): void; + private handleKeydown; + private handleKeyup; + private renderIcon; + render(): void; +} diff --git a/client/src/framework/types/badge.d.ts b/client/src/framework/types/badge.d.ts new file mode 100644 index 0000000..1b8b6a0 --- /dev/null +++ b/client/src/framework/types/badge.d.ts @@ -0,0 +1,12 @@ +import Component from "~brixi/component"; +export interface IBadge { + value: number; + offsetX: number; + offsetY: number; +} +export default class Badge extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + render(): void; +} diff --git a/client/src/framework/types/bootstrap.d.ts b/client/src/framework/types/bootstrap.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/client/src/framework/types/bootstrap.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/client/src/framework/types/breadcrumb-trail.d.ts b/client/src/framework/types/breadcrumb-trail.d.ts new file mode 100644 index 0000000..43bb2ef --- /dev/null +++ b/client/src/framework/types/breadcrumb-trail.d.ts @@ -0,0 +1,20 @@ +import Component from "~brixi/component"; +interface ILink { + label?: string; + icon?: string; + ariaLabel?: string; + id: string; +} +export interface IBreadcrumbTrail { + links: Array; +} +export default class BreadcrumbTrail extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + private handleClick; + private renderIcon; + private renderLink; + render(): void; +} +export {}; diff --git a/client/src/framework/types/button.d.ts b/client/src/framework/types/button.d.ts new file mode 100644 index 0000000..5e061f3 --- /dev/null +++ b/client/src/framework/types/button.d.ts @@ -0,0 +1,29 @@ +import Component from "~brixi/component"; +export type ButtonKind = "solid" | "outline" | "text"; +export type ButtonColor = "primary" | "danger" | "grey" | "success" | "warning" | "white"; +export type ButtonShape = "pill" | "round" | "sharp" | "default"; +export type ButtonSize = "default" | "slim" | "large"; +export type ButtonType = "submit" | "button" | "reset"; +export interface IButton { + label: string; + icon: string; + iconPosition: "left" | "right" | "center"; + kind: ButtonKind; + color: ButtonColor; + shape: ButtonShape; + size: ButtonSize; + disabled: boolean; + type: ButtonType; +} +export default class Button extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + private renderIcon; + private renderLabel; + private dispatchClick; + private handleClick; + private handleKeydown; + private handleKeyup; + render(): void; +} diff --git a/client/src/framework/types/checkbox-group.d.ts b/client/src/framework/types/checkbox-group.d.ts new file mode 100644 index 0000000..e54b5cb --- /dev/null +++ b/client/src/framework/types/checkbox-group.d.ts @@ -0,0 +1,21 @@ +import type { ICheckbox } from "~brixi/components/checkbox/checkbox"; +import "~brixi/components/checkbox/checkbox"; +import Component from "~brixi/component"; +export interface ICheckboxGroup { + options: Array; + instructions: string; + disabled: boolean; + label: string; + name: string; +} +export default class CheckboxGroup extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + getName(): string; + getValue(): Array; + reset(): void; + clearError(): void; + setError(error: string): void; + render(): void; +} diff --git a/client/src/framework/types/checkbox.d.ts b/client/src/framework/types/checkbox.d.ts new file mode 100644 index 0000000..4ab755c --- /dev/null +++ b/client/src/framework/types/checkbox.d.ts @@ -0,0 +1,27 @@ +import Component from "~brixi/component"; +export interface ICheckbox { + label: string; + required: boolean; + name: string; + checked: boolean; + error: string; + disabled: boolean; + type: "check" | "line"; + value: string | number; +} +export default class Checkbox extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + private handleChange; + private handleKeydown; + private handleKeyup; + getName(): string; + getValue(): string | number | null; + reset(): void; + clearError(): void; + setError(error: string): void; + validate(): boolean; + private renderIcon; + render(): void; +} diff --git a/client/src/framework/types/code-viewer.d.ts b/client/src/framework/types/code-viewer.d.ts new file mode 100644 index 0000000..3140437 --- /dev/null +++ b/client/src/framework/types/code-viewer.d.ts @@ -0,0 +1,18 @@ +import SuperComponent from "@codewithkyle/supercomponent"; +type SourceCode = { + ext: string; + raw: string; +}; +type CodeViewerData = { + sourceCode: Array; + activeExt: string; +}; +export default class CodeViewer extends SuperComponent { + private component; + constructor(component: string); + private fetchFiles; + private switchSource; + private copyToClipboard; + render(): void; +} +export {}; diff --git a/client/src/framework/types/color-input.d.ts b/client/src/framework/types/color-input.d.ts new file mode 100644 index 0000000..b224885 --- /dev/null +++ b/client/src/framework/types/color-input.d.ts @@ -0,0 +1,15 @@ +import { IInputBase, InputBase } from "../input-base"; +import "~brixi/utils/strings"; +export interface IColorInput extends IInputBase { + value: string; + label: string; + readOnly: boolean; +} +export default class ColorInput extends InputBase { + private inputId; + constructor(); + static get observedAttributes(): string[]; + validate(): boolean; + private handleInput; + render(): void; +} diff --git a/client/src/framework/types/component.d.ts b/client/src/framework/types/component.d.ts new file mode 100644 index 0000000..43c9a9e --- /dev/null +++ b/client/src/framework/types/component.d.ts @@ -0,0 +1,4 @@ +import SuperComponent from "@codewithkyle/supercomponent"; +export default class Component extends SuperComponent { + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void; +} diff --git a/client/src/framework/types/context-menu.d.ts b/client/src/framework/types/context-menu.d.ts new file mode 100644 index 0000000..a28f8ea --- /dev/null +++ b/client/src/framework/types/context-menu.d.ts @@ -0,0 +1,23 @@ +import SuperComponent from "@codewithkyle/supercomponent"; +export interface ContextMenuItem { + label: string; + hotkey?: string; + callback: Function; +} +export interface IContextMenu { + items: ContextMenuItem[]; + x: number; + y: number; +} +export interface ContextMenuSettings { + items: ContextMenuItem[]; + x: number; + y: number; +} +export default class ContextMenu extends SuperComponent { + constructor(settings: ContextMenuSettings); + connected(): void; + private handleItemClick; + private renderItem; + render(): void; +} diff --git a/client/src/framework/types/date-input.d.ts b/client/src/framework/types/date-input.d.ts new file mode 100644 index 0000000..33eaf54 --- /dev/null +++ b/client/src/framework/types/date-input.d.ts @@ -0,0 +1,31 @@ +import { IInputBase, InputBase } from "../input-base"; +export interface IDateInput extends IInputBase { + label: string; + instructions: string; + autocomplete: string; + autocapitalize: "off" | "on"; + icon: string; + placeholder: string; + autofocus: boolean; + value: string; + dateFormat: string; + displayFormat: string; + enableTime: boolean; + minDate: string; + maxDate: string; + mode: "multiple" | "single" | "range"; + disableCalendar: boolean; + timeFormat: "24" | "12"; + prevValue: string | number; +} +export default class DateInput extends InputBase { + private firstRender; + private inputId; + constructor(); + static get observedAttributes(): string[]; + private handleInput; + private renderCopy; + private renderIcon; + private renderLabel; + render(): void; +} diff --git a/client/src/framework/types/divider.d.ts b/client/src/framework/types/divider.d.ts new file mode 100644 index 0000000..00e37b1 --- /dev/null +++ b/client/src/framework/types/divider.d.ts @@ -0,0 +1,14 @@ +import Component from "~brixi/component"; +export type DividerColor = "primary" | "success" | "warning" | "danger" | "black" | "grey"; +export interface IDivider { + label: string; + color: DividerColor; + layout: "horizontal" | "vertical"; + type: "solid" | "dashed" | "dotted"; +} +export default class Divider extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + render(): void; +} diff --git a/client/src/framework/types/doc-viewer.d.ts b/client/src/framework/types/doc-viewer.d.ts new file mode 100644 index 0000000..121ba88 --- /dev/null +++ b/client/src/framework/types/doc-viewer.d.ts @@ -0,0 +1,11 @@ +import SuperComponent from "@codewithkyle/supercomponent"; +type DocViewerData = { + html: string; +}; +export default class DocViewer extends SuperComponent { + private component; + constructor(component: string); + private fetchDoc; + render(): void; +} +export {}; diff --git a/client/src/framework/types/download-button.d.ts b/client/src/framework/types/download-button.d.ts new file mode 100644 index 0000000..2c29dfd --- /dev/null +++ b/client/src/framework/types/download-button.d.ts @@ -0,0 +1,27 @@ +import Component from "~brixi/component"; +import type { ButtonColor, ButtonKind, ButtonShape, ButtonSize } from "../button/button"; +export interface IDownloadButton { + label: string; + icon: string; + kind: ButtonKind; + color: ButtonColor; + shape: ButtonShape; + size: ButtonSize; + url: RequestInfo; + options: RequestInit; + downloadingLabel: string; + workerURL: string; +} +export default class DownloadButton extends Component { + private indicator; + private downloading; + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + private fetchData; + private handleClick; + private handleKeydown; + private handleKeyup; + private renderIcon; + render(): void; +} diff --git a/client/src/framework/types/email-input.d.ts b/client/src/framework/types/email-input.d.ts new file mode 100644 index 0000000..e01eee6 --- /dev/null +++ b/client/src/framework/types/email-input.d.ts @@ -0,0 +1,15 @@ +import { InputBase } from "../input-base"; +import { IInput } from "../input/input"; +export default class EmailInput extends InputBase { + private inputId; + constructor(); + static get observedAttributes(): string[]; + validate(): boolean; + private handleInput; + private handleBlur; + private handleFocus; + private renderCopy; + private renderIcon; + private renderLabel; + render(): void; +} diff --git a/client/src/framework/types/env.d.ts b/client/src/framework/types/env.d.ts new file mode 100644 index 0000000..6ca1d19 --- /dev/null +++ b/client/src/framework/types/env.d.ts @@ -0,0 +1,49 @@ +export type NetworkType = "4g" | "3g" | "2g" | "slow-2g"; +export type DOMState = "loading" | "idling" | "booting"; +export type Browser = "chrome" | "safari" | "edge" | "chromium-edge" | "ie" | "firefox" | "unknown" | "opera"; +declare class Environment { + connection: NetworkType; + cpu: number; + memory: number | null; + domState: DOMState; + dataSaver: boolean; + browser: Browser; + private tickets; + constructor(); + boot(): void; + private handleNetworkChange; + /** + * Attempts to set the DOM to the `idling` state. The DOM will only idle when all `startLoading()` methods have been resolved. + * @param ticket - the `string` the was provided by the `startLoading()` method. + */ + stopLoading(ticket: string): void; + /** + * Sets the DOM to the `soft-loading` state. + * @returns a ticket `string` that is required to stop the loading state. + */ + startLoading(): string; + /** + * Sets the DOMs state attribute. + * DO NOT USE THIS METHOD. DO NOT MANUALLY SET THE DOMs STATE. + * @param newState - the new state of the document element + */ + private setDOMState; + /** + * Checks if the provided connection is greater than or equal to the current conneciton. + * @param requiredConnection - network connection string + */ + checkConnection(requiredConnection: any): boolean; + private setBrowser; + /** + * Binds the custom element to the class. + * @deprecated use `bind()` instead. + */ + mount(tagName: string, constructor: CustomElementConstructor): void; + /** + * Registers a Web Component by binding the Custom Element's tag name to the provided class. + */ + bind(tagName: string, constructor: CustomElementConstructor): void; + css(files: string | string[]): Promise; +} +declare const env: Environment; +export { env as default }; diff --git a/client/src/framework/types/file-download-worker.d.ts b/client/src/framework/types/file-download-worker.d.ts new file mode 100644 index 0000000..09c91fc --- /dev/null +++ b/client/src/framework/types/file-download-worker.d.ts @@ -0,0 +1,10 @@ +declare let reader: ReadableStreamDefaultReader; +declare let buffer: Uint8Array; +declare let total: number; +declare let recieved: number; +declare let running: boolean; +declare function tick(bytes: number): void; +declare function start(totalBytes: number): void; +declare function fail(error: any): void; +declare function done(): void; +declare function fetchData(url: RequestInfo, options: RequestInit): Promise; diff --git a/client/src/framework/types/filter-chip.d.ts b/client/src/framework/types/filter-chip.d.ts new file mode 100644 index 0000000..54b1c0f --- /dev/null +++ b/client/src/framework/types/filter-chip.d.ts @@ -0,0 +1,15 @@ +import Component from "~brixi/component"; +export interface IFilterChip { + label: string; + value: string | number; + checked: boolean; +} +export default class FilterChip extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + private handleClick; + private handleKeydown; + private handleKeyup; + render(): void; +} diff --git a/client/src/framework/types/form.d.ts b/client/src/framework/types/form.d.ts new file mode 100644 index 0000000..4924c80 --- /dev/null +++ b/client/src/framework/types/form.d.ts @@ -0,0 +1,15 @@ +import Component from "~brixi/component"; +export interface IForm { +} +export default class Form extends Component { + connected(): void; + start(): void; + stop(): void; + reset(): void; + serialize(): {}; + checkValidity(): boolean; + fail(errors: { + [name: string]: string; + }): void; + private handleReset; +} diff --git a/client/src/framework/types/general.d.ts b/client/src/framework/types/general.d.ts new file mode 100644 index 0000000..f50c78d --- /dev/null +++ b/client/src/framework/types/general.d.ts @@ -0,0 +1,13 @@ +/** + * A generic no operation (noop) function. + */ +export declare function noop(): void; +/** + * Debounce a function callback. + */ +export declare const debounce: (callback: Function, wait: number) => Function; +/** + * Maps a `DOMStringMap` onto an object. + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset + */ +export declare function parseDataset(dataset: DOMStringMap, model: T): T; diff --git a/client/src/framework/types/generic-list.d.ts b/client/src/framework/types/generic-list.d.ts new file mode 100644 index 0000000..8dd2c1a --- /dev/null +++ b/client/src/framework/types/generic-list.d.ts @@ -0,0 +1,22 @@ +import Component from "~brixi/component"; +export type ItemStyle = "disc" | "circle" | "decimal" | "leading-zero" | "square" | "custom"; +export type ListType = "ordered" | "unordered"; +export interface List { + type: ListType; + style?: ItemStyle; + items: Array; + sub?: List; + icon?: string; +} +export interface IGenericList { + list: List; +} +export default class GenericList extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + private renderStyleType; + private renderItem; + private renderList; + render(): void; +} diff --git a/client/src/framework/types/group-button.d.ts b/client/src/framework/types/group-button.d.ts new file mode 100644 index 0000000..2dae6a3 --- /dev/null +++ b/client/src/framework/types/group-button.d.ts @@ -0,0 +1,21 @@ +import type { ButtonType } from "~brixi/components/buttons/button/button"; +import Component from "~brixi/component"; +export interface IGroupButton { + buttons: Array<{ + label: string; + type?: ButtonType; + icon?: string; + id: string; + }>; + active?: string; +} +export default class GroupButton extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + private handleClick; + private renderIcon; + private renderLabel; + private renderButtons; + render(): void; +} diff --git a/client/src/framework/types/input-base.d.ts b/client/src/framework/types/input-base.d.ts new file mode 100644 index 0000000..9cee95d --- /dev/null +++ b/client/src/framework/types/input-base.d.ts @@ -0,0 +1,18 @@ +import Component from "~brixi/component"; +export interface IInputBase { + name: string; + error: string; + required: boolean; + value: any; + disabled: boolean; +} +export declare class InputBase extends Component { + constructor(); + connected(): Promise; + reset(): void; + clearError(): void; + setError(error: string): void; + validate(): boolean; + getName(): string; + getValue(): any; +} diff --git a/client/src/framework/types/input-chip.d.ts b/client/src/framework/types/input-chip.d.ts new file mode 100644 index 0000000..8034e9b --- /dev/null +++ b/client/src/framework/types/input-chip.d.ts @@ -0,0 +1,14 @@ +import Component from "~brixi/component"; +export interface IInputChip { + label: string; + value: string | number; +} +export default class InputChip extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): void; + private handleClick; + private handleKeydown; + private handleKeyup; + render(): void; +} diff --git a/client/src/framework/types/input.d.ts b/client/src/framework/types/input.d.ts new file mode 100644 index 0000000..ac13f1a --- /dev/null +++ b/client/src/framework/types/input.d.ts @@ -0,0 +1,31 @@ +import { InputBase, IInputBase } from "../input-base"; +export interface IInput extends IInputBase { + label: string; + instructions: string; + autocomplete: string; + autocapitalize: "off" | "on"; + icon: string; + placeholder: string; + maxlength: number; + minlength: number; + readOnly?: boolean; + datalist: string[]; + autofocus: boolean; + value: string; +} +export default class Input extends InputBase { + private inputId; + constructor(); + static get observedAttributes(): string[]; + validate(): boolean; + private handleInput; + private handleBlur; + private handleFocus; + private handleCopyClick; + private renderCopy; + private renderIcon; + private renderReadOnlyIcon; + private renderLabel; + private renderDatalist; + render(): void; +} diff --git a/client/src/framework/types/lightswitch.d.ts b/client/src/framework/types/lightswitch.d.ts new file mode 100644 index 0000000..23c08d7 --- /dev/null +++ b/client/src/framework/types/lightswitch.d.ts @@ -0,0 +1,31 @@ +import Component from "~brixi/component"; +export type LightswitchColor = "primary" | "success" | "warning" | "danger"; +export interface ILightswitch { + label: string; + instructions: string; + enabledLabel: string; + disabledLabel: string; + enabled: boolean; + name: string; + disabled: boolean; + color: LightswitchColor; + value: string | number; + required: boolean; +} +export default class Lightswitch extends Component { + private inputId; + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + getName(): string; + getValue(): string | number | null; + reset(): void; + clearError(): void; + setError(error: string): void; + validate(): boolean; + private handleChange; + private handleKeyup; + private handleKeydown; + private resize; + render(): void; +} diff --git a/client/src/framework/types/markdown-worker.d.ts b/client/src/framework/types/markdown-worker.d.ts new file mode 100644 index 0000000..bbfc067 --- /dev/null +++ b/client/src/framework/types/markdown-worker.d.ts @@ -0,0 +1 @@ +declare function marked(markdown: string): string; diff --git a/client/src/framework/types/markdown.d.ts b/client/src/framework/types/markdown.d.ts new file mode 100644 index 0000000..60d579e --- /dev/null +++ b/client/src/framework/types/markdown.d.ts @@ -0,0 +1,2 @@ +declare const renderMarkdown: any; +export { renderMarkdown }; diff --git a/client/src/framework/types/modals.d.ts b/client/src/framework/types/modals.d.ts new file mode 100644 index 0000000..2215bdd --- /dev/null +++ b/client/src/framework/types/modals.d.ts @@ -0,0 +1,68 @@ +import { TemplateResult } from "lit-html"; +import "~brixi/components/buttons/button/button"; +import "~brixi/components/buttons/submit-button/submit-button"; +import "~brixi/components/form/form"; +interface DangerousSettings { + title: string; + message: string; + confirm?: string; + cancel?: string; + width?: number; + callbacks?: { + cancel?: () => void; + confirm?: () => void; + }; +} +interface ConfirmSettings { + title: string; + message: string; + confirm?: string; + cancel?: string; + width?: number; + callbacks?: { + cancel?: () => void; + confirm?: () => void; + }; +} +interface PassiveSettings { + title: string; + message: string; + width?: number; + actions?: Array<{ + label: string; + callback: () => void; + }>; +} +interface FormSettings { + title?: string; + message?: string; + width?: number; + view: TemplateResult; + callbacks?: { + submit?: (data: { + [key: string]: any; + }, form: HTMLElement, modal: HTMLElement) => void; + cancel?: () => void; + }; + cancel?: string; + submit?: string; +} +interface RawSettings { + view: TemplateResult | HTMLElement; + width?: number; +} +declare class ModalMaker { + raw(settings: RawSettings): ModalComponent; + form(settings: FormSettings): void; + passive(settings: PassiveSettings): void; + confirm(settings: ConfirmSettings): void; + dangerous(settings: DangerousSettings): void; +} +declare const modals: ModalMaker; +export default modals; +declare class ModalComponent extends HTMLElement { + private view; + private width; + constructor(view: TemplateResult | HTMLElement, width: number, className: string); + private render; +} diff --git a/client/src/framework/types/multi-select.d.ts b/client/src/framework/types/multi-select.d.ts new file mode 100644 index 0000000..b9d5870 --- /dev/null +++ b/client/src/framework/types/multi-select.d.ts @@ -0,0 +1,48 @@ +import { TemplateResult } from "lit-html"; +import "~brixi/components/checkbox/checkbox"; +import Component from "~brixi/component"; +export type MultiSelectOption = { + label: string; + value: string | number; + checked?: boolean; + uid?: string; +}; +export interface IMultiSelect { + label: string; + icon: string; + instructions: string; + options: Array; + required: boolean; + name: string; + error: string; + disabled: boolean; + query: string; + placeholder: string; + search: "fuzzy" | "strict" | null; + separator: string; +} +export default class MultiSelect extends Component { + private inputId; + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + clearError(): void; + setError(error: string): void; + reset(): void; + getName(): string; + getValue(): any[]; + validate(): boolean; + private hasOneCheck; + private calcSelected; + private filterOptions; + private updateQuery; + private debounceFilterInput; + private handleFilterInput; + private checkAllCallback; + private checkboxCallback; + renderCopy(): string | TemplateResult<2 | 1>; + renderIcon(): string | TemplateResult<2 | 1>; + renderLabel(): string | TemplateResult<2 | 1>; + private renderSearch; + render(): void; +} diff --git a/client/src/framework/types/nav.d.ts b/client/src/framework/types/nav.d.ts new file mode 100644 index 0000000..0606ce6 --- /dev/null +++ b/client/src/framework/types/nav.d.ts @@ -0,0 +1,23 @@ +import SuperComponent from "@codewithkyle/supercomponent"; +type Link = { + name: string; + children: Array; + slug: string; +}; +type Navigation = Array; +type NavData = { + navigation: Navigation; + active: string; +}; +export default class Nav extends SuperComponent { + constructor(); + private fetchNavigation; + private navigate; + private toggleGroup; + private handleMenuClick; + private renderLink; + private renderLinkWithChildren; + render(): void; + connected(): void; +} +export {}; diff --git a/client/src/framework/types/number-input.d.ts b/client/src/framework/types/number-input.d.ts new file mode 100644 index 0000000..7ccabe4 --- /dev/null +++ b/client/src/framework/types/number-input.d.ts @@ -0,0 +1,26 @@ +import { InputBase, IInputBase } from "../input-base"; +interface INumberInput extends IInputBase { + label: string; + instructions: string; + icon: string; + placeholder: string; + autofocus: boolean; + value: number | null; + min: number; + max: number; + step: number; +} +export default class NumberInput extends InputBase { + private inputId; + constructor(); + static get observedAttributes(): string[]; + validate(): boolean; + private handleInput; + private handleBlur; + private handleFocus; + private renderCopy; + private renderIcon; + private renderLabel; + render(): void; +} +export {}; diff --git a/client/src/framework/types/numpy.d.ts b/client/src/framework/types/numpy.d.ts new file mode 100644 index 0000000..6642598 --- /dev/null +++ b/client/src/framework/types/numpy.d.ts @@ -0,0 +1,7 @@ +export declare function randomInt(min: number, max: number): number; +export declare function randomFloat(min: number, max: number, decimals?: number): number; +/** + * Converts numbers to a percentage. + * @example (4, 10) => 40 + */ +export declare function calcPercent(value: number, max: number): number; diff --git a/client/src/framework/types/overflow-button.d.ts b/client/src/framework/types/overflow-button.d.ts new file mode 100644 index 0000000..076834a --- /dev/null +++ b/client/src/framework/types/overflow-button.d.ts @@ -0,0 +1,21 @@ +import { OverflowItem } from "~brixi/components/overflow-menu/overflow-menu"; +import Component from "~brixi/component"; +import type { ButtonColor, ButtonKind, ButtonShape, ButtonSize } from "../button/button"; +export interface IOverflowButton { + icon: string; + iconPosition: "left" | "right" | "center"; + kind: ButtonKind; + color: ButtonColor; + shape: ButtonShape; + size: ButtonSize; + disabled: boolean; + items: Array; +} +export default class OverflowButton extends Component { + private uid; + constructor(); + static get observedAttributes(): string[]; + connected(): void; + private handleClick; + render(): void; +} diff --git a/client/src/framework/types/overflow-menu.d.ts b/client/src/framework/types/overflow-menu.d.ts new file mode 100644 index 0000000..54a786b --- /dev/null +++ b/client/src/framework/types/overflow-menu.d.ts @@ -0,0 +1,21 @@ +import Component from "~brixi/component"; +export interface OverflowItem { + label: string; + id: string; + icon?: string; + danger?: boolean; +} +export interface IOverflowMenu { + items: Array; + uid: string; + offset?: number; + target: HTMLElement; + callback: (id: string) => void; +} +export default class OverflowMenu extends Component { + constructor(settings: IOverflowMenu); + connected(): void; + private handleItemClick; + private renderItem; + render(): void; +} diff --git a/client/src/framework/types/pagination.d.ts b/client/src/framework/types/pagination.d.ts new file mode 100644 index 0000000..2183a48 --- /dev/null +++ b/client/src/framework/types/pagination.d.ts @@ -0,0 +1,19 @@ +import "~brixi/components/buttons/button/button"; +import Component from "~brixi/component"; +export interface IPagination { + totalPages: number; + activePage: number; +} +export default class Pagination extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + back(): void; + forward(): void; + jumpToPage(pageNumber: number): void; + private handleBack; + private handleForward; + private processPageChange; + private calcVisiblePageNumbers; + render(): void; +} diff --git a/client/src/framework/types/password-input.d.ts b/client/src/framework/types/password-input.d.ts new file mode 100644 index 0000000..dbf5d3d --- /dev/null +++ b/client/src/framework/types/password-input.d.ts @@ -0,0 +1,29 @@ +import { InputBase, IInputBase } from "../input-base"; +interface IPasswordInput extends IInputBase { + label: string; + instructions: string; + autocomplete: string; + icon: string; + placeholder: string; + maxlength: number; + minlength: number; + autofocus: boolean; + value: string; + type: "text" | "password"; +} +export default class PasswordInput extends InputBase { + private inputId; + constructor(); + static get observedAttributes(): string[]; + validate(): boolean; + private toggleVisibility; + private handleInput; + private handleBlur; + private handleFocus; + private renderCopy; + private renderIcon; + private renderLabel; + private renderEyeIcon; + render(): void; +} +export {}; diff --git a/client/src/framework/types/phone-input.d.ts b/client/src/framework/types/phone-input.d.ts new file mode 100644 index 0000000..91956ab --- /dev/null +++ b/client/src/framework/types/phone-input.d.ts @@ -0,0 +1,31 @@ +import { InputBase, IInputBase } from "../input-base"; +interface IPhoneInput extends IInputBase { + label: string; + instructions: string; + autocomplete: string; + icon: string; + placeholder: string; + datalist: string[]; + autofocus: boolean; + value: string; +} +export default class PhoneInput extends InputBase { + private inputId; + constructor(); + static get observedAttributes(): string[]; + validate(): boolean; + /** + * Formats phone number string (US) + * @see https://stackoverflow.com/a/8358141 + * @license https://creativecommons.org/licenses/by-sa/4.0/ + */ + private formatPhoneNumber; + private handleBlur; + private handleFocus; + private handleInput; + private renderCopy; + private renderIcon; + private renderLabel; + render(): void; +} +export {}; diff --git a/client/src/framework/types/pos.d.ts b/client/src/framework/types/pos.d.ts new file mode 100644 index 0000000..6ec4f49 --- /dev/null +++ b/client/src/framework/types/pos.d.ts @@ -0,0 +1,14 @@ +declare class Positions { + window: { + innerWidth: number; + innerHeight: number; + outterWidth: number; + outterHeight: number; + }; + constructor(); + private doResize; + positionElement(el: HTMLElement, x: number, y: number): void; + positionElementToElement(el: HTMLElement, target: HTMLElement, offset?: number): void; +} +declare const pos: Positions; +export default pos; diff --git a/client/src/framework/types/progress-badge.d.ts b/client/src/framework/types/progress-badge.d.ts new file mode 100644 index 0000000..368451c --- /dev/null +++ b/client/src/framework/types/progress-badge.d.ts @@ -0,0 +1,16 @@ +import "~brixi/components/progress/progress-indicator/progress-indicator"; +import Component from "~brixi/component"; +export interface IProgressBadge { + label: string; + total: number; + color: "grey" | "primary" | "success" | "warning" | "danger"; +} +export default class ProgressBadge extends Component { + private indicator; + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + tick(): void; + reset(): void; + render(): void; +} diff --git a/client/src/framework/types/progress-indicator.d.ts b/client/src/framework/types/progress-indicator.d.ts new file mode 100644 index 0000000..f003535 --- /dev/null +++ b/client/src/framework/types/progress-indicator.d.ts @@ -0,0 +1,23 @@ +import Component from "~brixi/component"; +export interface IProgressIndicator { + size: number; + tick: number; + total: number; + color: "grey" | "primary" | "success" | "warning" | "danger" | "white"; +} +export default class ProgressIndicator extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + /** + * Resets the `tick` value to `0`. + */ + reset(): void; + tick(amount?: number): void; + /** + * Sets the total and resets the `tick` value to `0`. + */ + setTotal(total: number): void; + private calcDashOffset; + render(): void; +} diff --git a/client/src/framework/types/progress-label.d.ts b/client/src/framework/types/progress-label.d.ts new file mode 100644 index 0000000..4a3d1f8 --- /dev/null +++ b/client/src/framework/types/progress-label.d.ts @@ -0,0 +1,17 @@ +import Component from "~brixi/component"; +import "../progress-indicator/progress-indicator"; +export interface IProgressLabel { + title: string; + subtitle: string; + total: number; +} +export default class ProgressLabel extends Component { + private indicator; + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + tick(): void; + reset(): void; + setProgress(subtitle: string): void; + render(): void; +} diff --git a/client/src/framework/types/progress-toast.d.ts b/client/src/framework/types/progress-toast.d.ts new file mode 100644 index 0000000..4f17f0e --- /dev/null +++ b/client/src/framework/types/progress-toast.d.ts @@ -0,0 +1,18 @@ +import Component from "~brixi/component"; +import "../progress-indicator/progress-indicator"; +export interface IProgressToast { + title: string; + subtitle: string; + total: number; +} +export default class ProgressToast extends Component { + private indicator; + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + tick(amount?: number): void; + reset(): void; + setProgress(subtitle: string): void; + private finishedCallback; + render(): void; +} diff --git a/client/src/framework/types/radio-group.d.ts b/client/src/framework/types/radio-group.d.ts new file mode 100644 index 0000000..338764d --- /dev/null +++ b/client/src/framework/types/radio-group.d.ts @@ -0,0 +1,23 @@ +import "~brixi/components/radio/radio"; +import Component from "~brixi/component"; +import type { IRadio } from "~brixi/components/radio/radio"; +export interface IRadioGroup { + options: Array; + instructions: string; + disabled: boolean; + label: string; + name: string; + required: boolean; +} +export default class RadioGroup extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + getName(): string; + getValue(): string | number | null; + reset(): void; + clearError(): void; + setError(error: string): void; + validate(): boolean; + render(): void; +} diff --git a/client/src/framework/types/radio.d.ts b/client/src/framework/types/radio.d.ts new file mode 100644 index 0000000..b71a33e --- /dev/null +++ b/client/src/framework/types/radio.d.ts @@ -0,0 +1,25 @@ +import Component from "~brixi/component"; +export interface IRadio { + label: string; + required: boolean; + name: string; + checked: boolean; + disabled: boolean; + value: string | number; +} +export default class Radio extends Component { + private inputId; + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + getName(): string; + getValue(): string | number | null; + reset(): void; + clearError(): void; + setError(error: string): void; + validate(): boolean; + private handleChange; + private handleKeydown; + private handleKeyup; + render(): void; +} diff --git a/client/src/framework/types/range-slider.d.ts b/client/src/framework/types/range-slider.d.ts new file mode 100644 index 0000000..d72a265 --- /dev/null +++ b/client/src/framework/types/range-slider.d.ts @@ -0,0 +1,34 @@ +import { InputBase, IInputBase } from "../input-base"; +export interface IRangeSlider extends IInputBase { + label: string; + instructions: string; + icon: string; + readOnly: boolean; + autofocus: boolean; + min: number; + max: number; + step: number; + manual: boolean; + value: number; + minIcon: string; + maxIcon: string; +} +export default class RangeSlider extends InputBase { + private fillPercentage; + private inputId; + constructor(); + static get observedAttributes(): string[]; + private handleChange; + private handleInput; + private handleBlur; + private handleFocus; + private handleIconClick; + reset(): void; + validate(): boolean; + private renderCopy; + private renderLabel; + private renderManualInput; + private renderFill; + private renderIcon; + render(): void; +} diff --git a/client/src/framework/types/resizer.d.ts b/client/src/framework/types/resizer.d.ts new file mode 100644 index 0000000..814cb30 --- /dev/null +++ b/client/src/framework/types/resizer.d.ts @@ -0,0 +1,14 @@ +export default class Resizer extends HTMLElement { + private width; + private _resizeElement; + private x; + private dragging; + private maxWidth; + private container; + constructor(); + private initDrag; + private doDrag; + private stopDrag; + private resize; + connectedCallback(): void; +} diff --git a/client/src/framework/types/select.d.ts b/client/src/framework/types/select.d.ts new file mode 100644 index 0000000..d8cbded --- /dev/null +++ b/client/src/framework/types/select.d.ts @@ -0,0 +1,37 @@ +import { TemplateResult } from "lit-html"; +import Component from "~brixi/component"; +export type SelectOption = { + label: string; + value: string; +}; +export interface ISelect { + label: string; + icon: string | HTMLElement; + instructions: string; + options: Array; + required: boolean; + name: string; + error: string; + value: any; + disabled: boolean; + autofocus: boolean; +} +export default class Select extends Component { + private inputId; + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + renderCopy(): string | TemplateResult; + renderIcon(): string | TemplateResult; + clearError(): void; + reset(): void; + setError(error: string): void; + validate(): boolean; + private handleChange; + getName(): string; + getValue(): any; + handleBlur: EventListener; + private handleFocus; + renderLabel(): string | TemplateResult; + render(): void; +} diff --git a/client/src/framework/types/soundscape.d.ts b/client/src/framework/types/soundscape.d.ts new file mode 100644 index 0000000..82bef91 --- /dev/null +++ b/client/src/framework/types/soundscape.d.ts @@ -0,0 +1,45 @@ +interface ISound { + ctx: AudioContext; + gain: GainNode; + buffer: AudioBuffer; +} +/** + * @see https://material.io/design/sound/sound-resources.html + * @license CC-BY-4.0 + */ +declare class Soundscape { + private sounds; + private soundState; + private hasTouched; + private hasPointer; + constructor(); + private addButtonListeners; + private mousemove; + private mouseleave; + private mouseover; + private focus; + private click; + private save; + toggleSound(handle: string, isEnable: boolean): void; + /** + * Creates a new sound source. + * Returns `null` if the sound does not exist OR if playback has been disabled. + **/ + play(handle: string, loop?: boolean): AudioBufferSourceNode | null; + /** + * Pauses a sound source. + */ + pause(handle: string): void; + /** + * Resumes a sound source. + */ + resume(handle: string): void; + setVolume(handle: string, volume: number): void; + getVolume(handle: string): number; + add(handle: string, src: string, force?: boolean): Promise; + get(handle: string): ISound | null; + private createSound; + load(): Promise; +} +declare const sound: Soundscape; +export { sound as default }; diff --git a/client/src/framework/types/spinner.d.ts b/client/src/framework/types/spinner.d.ts new file mode 100644 index 0000000..ce6c4c4 --- /dev/null +++ b/client/src/framework/types/spinner.d.ts @@ -0,0 +1,11 @@ +import Component from "~brixi/component"; +export interface ISpinner { + color: "primary" | "grey"; + size: number; +} +export default class Spinner extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + render(): void; +} diff --git a/client/src/framework/types/split-button.d.ts b/client/src/framework/types/split-button.d.ts new file mode 100644 index 0000000..07bb599 --- /dev/null +++ b/client/src/framework/types/split-button.d.ts @@ -0,0 +1,24 @@ +import { OverflowItem } from "~brixi/components/overflow-menu/overflow-menu"; +import Component from "~brixi/component"; +import type { ButtonType } from "../button/button"; +export interface ISplitButton { + type: ButtonType; + label: string; + icon?: string; + buttons: OverflowItem[]; + id: string; +} +export default class SplitButton extends Component { + private uid; + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + private hideMenu; + private handlePrimaryClick; + private openMenu; + private renderIcon; + private renderLabel; + private renderPrimaryButton; + private renderMenuButtons; + render(): void; +} diff --git a/client/src/framework/types/status-badge.d.ts b/client/src/framework/types/status-badge.d.ts new file mode 100644 index 0000000..bc7673c --- /dev/null +++ b/client/src/framework/types/status-badge.d.ts @@ -0,0 +1,13 @@ +import Component from "~brixi/component"; +export interface IStatusBadge { + color: "grey" | "primary" | "success" | "warning" | "danger"; + label: string; + dot: "right" | "left" | null; + icon: string; +} +export default class StatusBadge extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + render(): void; +} diff --git a/client/src/framework/types/steps.d.ts b/client/src/framework/types/steps.d.ts new file mode 100644 index 0000000..471c6aa --- /dev/null +++ b/client/src/framework/types/steps.d.ts @@ -0,0 +1,21 @@ +import Component from "~brixi/component"; +export interface Step { + label: string; + description?: string; + name: string; +} +export interface ISteps { + steps: Array; + activeStep: number; + step: string; + layout: "horizontal" | "vertical"; +} +export default class Steps extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + private handleClick; + private renderVerticalStep; + private renderHorizontalStep; + render(): void; +} diff --git a/client/src/framework/types/strings.d.ts b/client/src/framework/types/strings.d.ts new file mode 100644 index 0000000..a588747 --- /dev/null +++ b/client/src/framework/types/strings.d.ts @@ -0,0 +1,3 @@ +interface String { + ltrim(char: string): string; +} diff --git a/client/src/framework/types/submit-button.d.ts b/client/src/framework/types/submit-button.d.ts new file mode 100644 index 0000000..01acbc2 --- /dev/null +++ b/client/src/framework/types/submit-button.d.ts @@ -0,0 +1,19 @@ +import "~brixi/components/progress/spinner/spinner"; +import Component from "~brixi/component"; +import type { ButtonSize } from "../button/button"; +export interface ISubmitButton { + label: string; + icon: string; + size: ButtonSize; + disabled: boolean; + submittingLabel: string; +} +export default class SubmitButton extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + private handleClick; + private renderIcon; + private renderLabel; + render(): void; +} diff --git a/client/src/framework/types/suggestion-chip.d.ts b/client/src/framework/types/suggestion-chip.d.ts new file mode 100644 index 0000000..9229a6e --- /dev/null +++ b/client/src/framework/types/suggestion-chip.d.ts @@ -0,0 +1,14 @@ +import Component from "~brixi/component"; +export interface ISuggestionChip { + label: string; + value: string | number; +} +export default class SuggestionChip extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): void; + private handleClick; + private handleKeydown; + private handleKeyup; + render(): void; +} diff --git a/client/src/framework/types/tabs.d.ts b/client/src/framework/types/tabs.d.ts new file mode 100644 index 0000000..2759733 --- /dev/null +++ b/client/src/framework/types/tabs.d.ts @@ -0,0 +1,30 @@ +import "~brixi/components/buttons/button/button"; +import Component from "~brixi/component"; +export interface ITab { + label: string; + value: string | number; + icon?: string; + active?: boolean; + index?: number; +} +export interface ITabs { + tabs: Array; + active: number; + sortable: boolean; + expandable: boolean; + shrinkable: boolean; +} +export default class Tabs extends Component { + private firstRender; + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + getOrder(): Array; + private handleClick; + callback(value: string | number, index: number): void; + private sort; + private addTab; + resetIndexes(): void; + private renderAddButton; + render(): void; +} diff --git a/client/src/framework/types/textarea.d.ts b/client/src/framework/types/textarea.d.ts new file mode 100644 index 0000000..337a1c6 --- /dev/null +++ b/client/src/framework/types/textarea.d.ts @@ -0,0 +1,39 @@ +import { TemplateResult } from "lit-html"; +import Component from "~brixi/component"; +export interface ITextarea { + label: string; + name: string; + instructions: string; + error: string; + required: boolean; + autocomplete: string; + placeholder: string; + value: string; + maxlength: number; + minlength: number; + disabled: boolean; + readOnly: boolean; + rows: number; + autofocus: boolean; +} +export default class Textarea extends Component { + private inputId; + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + clearError(): void; + reset(): void; + setError(error: string): void; + validate(): boolean; + getName(): string; + getValue(): string; + handleBlur: EventListener; + handleFocus: EventListener; + handleInput: EventListener; + private handleCopyClick; + renderCopy(): string | TemplateResult; + renderLabel(): string | TemplateResult; + private renderReadOnlyIcon; + renderCounter(): string | TemplateResult; + render(): void; +} diff --git a/client/src/framework/types/toggle-button.d.ts b/client/src/framework/types/toggle-button.d.ts new file mode 100644 index 0000000..9aac041 --- /dev/null +++ b/client/src/framework/types/toggle-button.d.ts @@ -0,0 +1,26 @@ +import "~brixi/components/buttons/button/button"; +import type { IButton } from "~brixi/components/buttons/button/button"; +import Component from "~brixi/component"; +interface Button extends IButton { + id: string; +} +export interface IToggleButton { + state: string; + states: Array; + buttons: { + [state: string]: Button; + }; + instructions: string; + index: number; +} +export default class ToggleButton extends Component { + constructor(); + static get observedAttributes(): string[]; + connected(): Promise; + private handleAction; + private handleClick; + private renderButton; + private renderInstructions; + render(): void; +} +export {}; diff --git a/client/src/framework/types/view.d.ts b/client/src/framework/types/view.d.ts new file mode 100644 index 0000000..60f8fe4 --- /dev/null +++ b/client/src/framework/types/view.d.ts @@ -0,0 +1,16 @@ +import SuperComponent from "@codewithkyle/supercomponent"; +type ViewData = { + component: string; + view: "demo" | "docs" | "code"; +}; +export default class View extends SuperComponent { + private inboxId; + constructor(); + private inbox; + private load; + private switchView; + private renderContent; + connected(): void; + render(): void; +} +export {}; diff --git a/client/src/framework/utils/general.ts b/client/src/framework/utils/general.ts new file mode 100644 index 0000000..bbf21f5 --- /dev/null +++ b/client/src/framework/utils/general.ts @@ -0,0 +1,38 @@ +/** + * A generic no operation (noop) function. + */ +export function noop(): void { + return; +} + +/** + * Debounce a function callback. + */ +export const debounce = (callback: Function, wait: number): Function => { + let timeoutId = null; + return (...args) => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + callback.apply(null, args); + }, wait); + }; +}; + +/** + * Maps a `DOMStringMap` onto an object. + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset + */ +export function parseDataset(dataset: DOMStringMap, model: T): T { + let out: T = { ...model }; + Object.keys(dataset).map((key) => { + // @ts-ignore + if (key in out) { + try { + out[key] = JSON.parse(dataset[key]); + } catch (e) { + out[key] = dataset[key]; + } + } + }); + return out; +} diff --git a/client/src/framework/utils/numpy.ts b/client/src/framework/utils/numpy.ts new file mode 100644 index 0000000..632287f --- /dev/null +++ b/client/src/framework/utils/numpy.ts @@ -0,0 +1,16 @@ +export function randomInt(min: number, max: number) { + return Math.round(Math.random() * (max - min) + min); +} + +export function randomFloat(min: number, max: number, decimals = 2) { + return parseFloat((Math.random() * (max - min) + min).toFixed(decimals)); +} + +/** + * Converts numbers to a percentage. + * @example (4, 10) => 40 + */ +export function calcPercent(value: number, max: number): number { + const percent = (value / max) * 100; + return Math.round(percent); +} diff --git a/client/src/framework/utils/strings.ts b/client/src/framework/utils/strings.ts new file mode 100644 index 0000000..2ddd65c --- /dev/null +++ b/client/src/framework/utils/strings.ts @@ -0,0 +1,8 @@ +interface String { + ltrim(char: string): string; +} + +// @ts-ignore +String.prototype.ltrim = function (char: string) { + return this.replace(new RegExp(`^${char}+`), ""); +}; diff --git a/client/src/framework/workers/file-download-worker.ts b/client/src/framework/workers/file-download-worker.ts new file mode 100644 index 0000000..2354d69 --- /dev/null +++ b/client/src/framework/workers/file-download-worker.ts @@ -0,0 +1,72 @@ +let reader: ReadableStreamDefaultReader = null; +let buffer: Uint8Array = null; +let total: number = 0; +let recieved: number = 0; +let running: boolean = false; + +function tick(bytes: number) { + recieved += bytes; + //@ts-ignore + self.postMessage({ + type: "tick", + data: bytes, + }); +} + +function start(totalBytes: number) { + total = totalBytes; + //@ts-ignore + self.postMessage({ + type: "start", + data: total, + }); +} + +function fail(error) { + //@ts-ignore + self.postMessage({ + type: "error", + data: error, + }); +} + +function done() { + //@ts-ignore + self.postMessage({ + type: "done", + data: buffer, + }); +} + +async function fetchData(url: RequestInfo, options: RequestInit) { + try { + running = true; + const response = await fetch(url, options); + if (response.ok) { + start(parseInt(response.headers.get("content-length"))); + const stream = response.body; + reader = stream.getReader(); + recieved = 0; + buffer = new Uint8Array(total); + while (recieved < total) { + const { done, value } = await reader.read(); + buffer.set(value, recieved); + tick(value.byteLength); + if (done) { + break; + } + } + done(); + } else { + fail(response.statusText); + } + } catch (e) { + fail(e); + } +} +self.onmessage = (e: MessageEvent) => { + if (!running) { + const { url, options } = e.data; + fetchData(url, options); + } +}; diff --git a/client/src/pages/tabletop-page/tabletop-component/fog-canvas/fog-canvas.ts b/client/src/pages/tabletop-page/tabletop-component/fog-canvas/fog-canvas.ts deleted file mode 100644 index fac050a..0000000 --- a/client/src/pages/tabletop-page/tabletop-component/fog-canvas/fog-canvas.ts +++ /dev/null @@ -1,226 +0,0 @@ -import SuperComponent from "@codewithkyle/supercomponent"; -import env from "~brixi/controllers/env"; -import { subscribe, unsubscribe } from "@codewithkyle/pubsub"; -import type { Image } from "~types/app"; -import room from "room"; -import TabeltopComponent from "../tabletop-component"; -import { send } from "~controllers/ws"; - -interface IFogCanvas { } -export default class FogCanvas extends SuperComponent{ - private canvas: HTMLCanvasElement; - private ctx: CanvasRenderingContext2D; - private time: number; - private gridSize: number; - private prefillFog: boolean; - private ticket: string; - private w: number; - private h: number; - private clearedCells: { - [key: string]: boolean, - } - private tabletop: TabeltopComponent; - private brushSize: number; - - constructor() { - super(); - this.w = 0; - this.h = 0; - this.canvas = document.createElement("canvas") as HTMLCanvasElement; - this.ctx = this.canvas.getContext("2d"); - this.tabletop = document.querySelector("tabletop-component"); - this.time = 0; - this.gridSize = 32; - this.prefillFog = false; - this.brushSize = 1; - this.clearedCells = {}; - subscribe("socket", this.inbox.bind(this)); - subscribe("fog", this.fogInbox.bind(this)); - } - - override async connected() { - await env.css(["fog-canvas"]); - this.appendChild(this.canvas); - } - - public convertViewportToTabletopPosition(clientX: number, clientY: number): Array { - const canvas = this.getBoundingClientRect(); - const x = Math.round(clientX - canvas.left + this.scrollLeft) / this.tabletop.zoom; - const y = Math.round(clientY - canvas.top + this.scrollTop) / this.tabletop.zoom; - return [x, y]; - } - - private fogInbox({ type, data }) { - const [x, y] = this.convertViewportToTabletopPosition(data.x, data.y); - switch (type) { - case "eraser": - this.brushSize = data.brushSize; - this.erase(x, y); - break; - case "fill": - this.brushSize = data.brushSize; - this.fill(x, y); - break; - default: - break; - } - } - - private inbox({ type, data }) { - switch (type) { - case "room:tabletop:fog:sync": - this.clearedCells = data.clearedCells; - this.renderFogOfWar(); - break; - case "room:tabletop:clear": - for (const key in this.clearedCells) { - this.clearedCells[key] = true; - } - this.renderFogOfWar(); - break; - case "room:tabletop:map:update": - const prevGridSize = this.gridSize; - this.gridSize = data.cellSize; - this.prefillFog = data.prefillFog; - this.renderFogOfWar(); - if (prevGridSize != this.gridSize) this.load(); - break; - default: - break; - } - } - - private fill(x: number, y: number) { - let x1, y1, x2, y2; - if (this.brushSize > 2) { - x1 = x - (this.gridSize * this.brushSize * 0.5) + (this.gridSize * 0.25); - y1 = y - (this.gridSize * this.brushSize * 0.5) + (this.gridSize * 0.25); - x2 = x + (this.gridSize * this.brushSize * 0.5) - (this.gridSize * 0.25); - y2 = y + (this.gridSize * this.brushSize * 0.5) - (this.gridSize * 0.25); - } else { - x1 = x - (this.gridSize * this.brushSize * 0.5); - y1 = y - (this.gridSize * this.brushSize * 0.5); - x2 = x + (this.gridSize * this.brushSize * 0.5); - y2 = y + (this.gridSize * this.brushSize * 0.5); - } - const cellsInRow = Math.ceil(this.w / this.gridSize); - const rows = Math.ceil(this.h / this.gridSize); - for (let i = 0; i < rows; i++) { - for (let j = 0; j < cellsInRow; j++) { - const cellX = j * this.gridSize; - const cellY = i * this.gridSize; - if (cellX >= x1 && cellX + this.gridSize < x2 && cellY >= y1 && cellY + this.gridSize < y2) { - const key = `${cellX}-${cellY}`; - if (key in this.clearedCells) { - this.clearedCells[key] = false; - this.debounceSyncClearCells(); - } - } - } - } - this.renderFogOfWar(); - } - - private erase(x: number, y: number) { - let x1, y1, x2, y2; - if (this.brushSize > 2) { - x1 = x - (this.gridSize * this.brushSize * 0.5) + (this.gridSize * 0.25); - y1 = y - (this.gridSize * this.brushSize * 0.5) + (this.gridSize * 0.25); - x2 = x + (this.gridSize * this.brushSize * 0.5) - (this.gridSize * 0.25); - y2 = y + (this.gridSize * this.brushSize * 0.5) - (this.gridSize * 0.25); - } else { - x1 = x - (this.gridSize * this.brushSize * 0.5); - y1 = y - (this.gridSize * this.brushSize * 0.5); - x2 = x + (this.gridSize * this.brushSize * 0.5); - y2 = y + (this.gridSize * this.brushSize * 0.5); - } - const cellsInRow = Math.ceil(this.w / this.gridSize); - const rows = Math.ceil(this.h / this.gridSize); - for (let i = 0; i < rows; i++) { - for (let j = 0; j < cellsInRow; j++) { - const cellX = j * this.gridSize; - const cellY = i * this.gridSize; - if (cellX >= x1 && cellX + this.gridSize < x2 && cellY >= y1 && cellY + this.gridSize < y2) { - const key = `${cellX}-${cellY}`; - this.clearedCells[key] = true; - this.debounceSyncClearCells(); - } - } - } - this.renderFogOfWar(); - } - - private syncClearCells() { - send("room:tabletop:fog:sync", this.clearedCells); - } - - private debounceSyncClearCells = this.debounce(this.syncClearCells.bind(this), 500); - - private renderFogOfWar() { - this.ctx.clearRect(0, 0, this.w, this.h); - if (room.isGM) { - this.ctx.globalAlpha = 0.6; - } - const cells = []; - const cellsInRow = Math.ceil(this.w / this.gridSize); - const rows = Math.ceil(this.h / this.gridSize); - for (let i = 0; i < rows; i++) { - for (let j = 0; j < cellsInRow; j++) { - const x = j * this.gridSize; - const y = i * this.gridSize; - const key = `${x}-${y}`; - if (key in this.clearedCells) { - if (this.clearedCells[key]) { - continue; - } - cells.push({ - x: x, - y: y, - }); - } - } - } - - this.ctx.fillStyle = "rgba(24,24,24)"; - for (let i = 0; i < cells.length; i++) { - const { x, y } = cells[i]; - this.ctx.fillRect(x, y, this.gridSize, this.gridSize); - } - } - - public load() { - this.clearedCells = {}; - const cellsInRow = Math.ceil(this.w / this.gridSize); - const rows = Math.ceil(this.h / this.gridSize); - for (let i = 0; i < rows; i++) { - for (let j = 0; j < cellsInRow; j++) { - const x = j * this.gridSize; - const y = i * this.gridSize; - const key = `${x}-${y}`; - this.clearedCells[key] = !this.prefillFog; - } - } - this.syncClearCells(); - } - - // @ts-ignore - override render(image: HTMLImageElement): void { - if (!image) { - this.w = 0; - this.h = 0; - this.canvas.width = this.w; - this.canvas.height = this.h; - this.canvas.style.width = `0px`; - this.canvas.style.height = `0px`; - } else { - this.w = image.width; - this.h = image.height; - this.canvas.width = this.w; - this.canvas.height = this.h; - this.canvas.style.width = `${image.width}px`; - this.canvas.style.height = `${image.height}px`; - } - this.renderFogOfWar(); - } -} -env.bind("fog-canvas", FogCanvas); diff --git a/client/src/pages/tabletop-page/tabletop-component/grid-canvas/grid-canvas.scss b/client/src/pages/tabletop-page/tabletop-component/grid-canvas/grid-canvas.scss deleted file mode 100644 index 0d663dc..0000000 --- a/client/src/pages/tabletop-page/tabletop-component/grid-canvas/grid-canvas.scss +++ /dev/null @@ -1,12 +0,0 @@ -grid-canvas{ - position: absolute; - top: 50%; - left: 50%; - pointer-events: none; - transform: translate(-50%, -50%); - opacity: 0.75; - - canvas{ - display: inline-block; - } -} diff --git a/client/src/pages/tabletop-page/tabletop-component/grid-canvas/grid-canvas.ts b/client/src/pages/tabletop-page/tabletop-component/grid-canvas/grid-canvas.ts deleted file mode 100644 index d8e09d4..0000000 --- a/client/src/pages/tabletop-page/tabletop-component/grid-canvas/grid-canvas.ts +++ /dev/null @@ -1,104 +0,0 @@ -import SuperComponent from "@codewithkyle/supercomponent"; -import env from "~brixi/controllers/env"; -import { subscribe, unsubscribe } from "@codewithkyle/pubsub"; -import type { Image } from "~types/app"; - -interface IGridCanvas { } -export default class GridCanvas extends SuperComponent{ - private canvas: HTMLCanvasElement; - private ctx: CanvasRenderingContext2D; - private time: number; - private gridSize: number; - private renderGrid: boolean; - private ticket: string; - private w: number; - private h: number; - - constructor() { - super(); - this.w = 0; - this.h = 0; - this.canvas = document.createElement("canvas") as HTMLCanvasElement; - this.ctx = this.canvas.getContext("2d"); - this.time = 0; - this.gridSize = 32; - this.renderGrid = false; - this.ticket = subscribe("socket", this.inbox.bind(this)); - } - - override async connected() { - await env.css(["grid-canvas"]); - this.appendChild(this.canvas); - } - - override disconnected() { - unsubscribe(this.ticket); - } - - private inbox({ type, data }) { - switch (type) { - case "room:tabletop:clear": - this.renderGrid = false; - this.renderGridLines(); - break; - case "room:tabletop:map:update": - this.renderGrid = data.renderGrid; - this.gridSize = data.cellSize; - this.renderGridLines(); - break; - default: - break; - } - } - - private renderGridLines() { - this.ctx.clearRect(0, 0, this.w, this.h); - if (this.renderGrid) { - const cells = []; - const cellsInRow = Math.ceil(this.w / this.gridSize); - const rows = Math.ceil(this.h / this.gridSize); - for (let i = 0; i < rows; i++) { - for (let j = 0; j < cellsInRow; j++) { - cells.push({ - x: j * this.gridSize, - y: i * this.gridSize, - }); - } - } - - this.ctx.strokeStyle = "rgb(0,0,0)"; - for (let i = 0; i < cells.length; i++) { - const { x, y } = cells[i]; - this.ctx.beginPath(); - this.ctx.moveTo(x, y); - this.ctx.lineTo(x, y + this.gridSize); - this.ctx.stroke(); - this.ctx.beginPath(); - this.ctx.moveTo(x, y); - this.ctx.lineTo(x + this.gridSize, y); - this.ctx.stroke(); - } - } - } - - // @ts-ignore - override render(image: HTMLImageElement): void { - if (!image) { - this.w = 0; - this.h = 0; - this.canvas.width = this.w; - this.canvas.height = this.h; - this.canvas.style.width = `0px`; - this.canvas.style.height = `0px`; - } else { - this.w = image.width; - this.h = image.height; - this.canvas.width = this.w; - this.canvas.height = this.h; - this.canvas.style.width = `${image.width}px`; - this.canvas.style.height = `${image.height}px`; - } - this.renderGridLines(); - } -} -env.bind("grid-canvas", GridCanvas); diff --git a/client/src/pages/tabletop-page/tabletop-component/fog-canvas/fog-canvas.scss b/client/src/pages/tabletop-page/tabletop-component/table-canvas/table-canvas.scss similarity index 92% rename from client/src/pages/tabletop-page/tabletop-component/fog-canvas/fog-canvas.scss rename to client/src/pages/tabletop-page/tabletop-component/table-canvas/table-canvas.scss index cf66fd5..6686be0 100644 --- a/client/src/pages/tabletop-page/tabletop-component/fog-canvas/fog-canvas.scss +++ b/client/src/pages/tabletop-page/tabletop-component/table-canvas/table-canvas.scss @@ -1,4 +1,4 @@ -fog-canvas{ +table-canvas{ position: absolute; top: 50%; left: 50%; diff --git a/client/src/pages/tabletop-page/tabletop-component/table-canvas/table-canvas.ts b/client/src/pages/tabletop-page/tabletop-component/table-canvas/table-canvas.ts new file mode 100644 index 0000000..22cba85 --- /dev/null +++ b/client/src/pages/tabletop-page/tabletop-component/table-canvas/table-canvas.ts @@ -0,0 +1,231 @@ +import SuperComponent from "@codewithkyle/supercomponent"; +import env from "~brixi/controllers/env"; +import { subscribe } from "@codewithkyle/pubsub"; +import room from "room"; +import TabeltopComponent from "../tabletop-component"; +import { send } from "~controllers/ws"; + +type Point = { + x: number, + y: number, +} +type FogOfWarShape = { + type: "poly" | "rect", + points: Array, +} + +interface ITableCanvas { } +export default class TableCanvas extends SuperComponent{ + private canvas: HTMLCanvasElement; + private fogCanvas: HTMLCanvasElement; + private fogctx: CanvasRenderingContext2D; + private imgctx: CanvasRenderingContext2D; + private time: number; + private renderGrid: boolean; + private gridSize: number; + private fogOfWar: boolean; + private ticket: string; + private w: number; + private h: number; + private fogOfWarShapes: Array; + private tabletop: TabeltopComponent; + private image: HTMLImageElement; + + constructor() { + super(); + this.w = 0; + this.h = 0; + this.canvas = document.createElement("canvas") as HTMLCanvasElement; + this.fogCanvas = document.createElement("canvas") as HTMLCanvasElement; + this.fogctx = this.fogCanvas.getContext("2d"); + this.imgctx = this.canvas.getContext("2d"); + this.tabletop = document.querySelector("tabletop-component"); + this.time = performance.now(); + this.renderGrid = false; + this.gridSize = 32; + this.fogOfWar = false; + this.fogOfWarShapes = []; + this.image = null; + subscribe("socket", this.inbox.bind(this)); + subscribe("fog", this.fogInbox.bind(this)); + } + + override async connected() { + await env.css(["table-canvas"]); + this.appendChild(this.canvas); + } + + public convertViewportToTabletopPosition(clientX: number, clientY: number): Array { + const canvas = this.getBoundingClientRect(); + const x = Math.round(clientX - canvas.left) / this.tabletop.zoom; + const y = Math.round(clientY - canvas.top) / this.tabletop.zoom; + return [x, y]; + } + + private fogInbox({ type, points }) { + const convertedPoints = []; + for (let i = 0; i < points.length; i++){ + const [x, y] = this.convertViewportToTabletopPosition(points[i].x, points[i].y); + convertedPoints.push({ x, y }); + } + switch (type) { + case "rect": + const newRect:FogOfWarShape = { + type: "rect", + points: convertedPoints, + }; + this.fogOfWarShapes.push(newRect); + this.sync(newRect); + break; + case "poly": + const newPoly:FogOfWarShape = { + type: "poly", + points: convertedPoints, + }; + this.fogOfWarShapes.push(newPoly); + this.sync(newPoly); + break; + default: + break; + } + } + + private inbox({ type, data }) { + switch (type) { + case "room:tabletop:fog:sync": + this.fogOfWar = data.fogOfWar; + this.fogOfWarShapes = data.fogOfWarShapes; + this.render(); + break; + case "room:tabletop:clear": + this.fogOfWarShapes = []; + this.render(); + break; + case "room:tabletop:map:update": + this.renderGrid = data.renderGrid; + this.gridSize = data.cellSize; + this.fogOfWar = data.prefillFog; + this.render(); + break; + default: + break; + } + } + + private sync(shape:FogOfWarShape) { + send("room:tabletop:fog:sync", shape); + } + + private revealShapes() { + this.fogctx.globalCompositeOperation = "destination-out"; + this.fogctx.globalAlpha = 1.0; + this.fogctx.fillStyle = "white"; + for (let i = 0; i < this.fogOfWarShapes.length; i++) { + switch (this.fogOfWarShapes[i].type){ + case "poly": + this.fogctx.beginPath(); + this.fogctx.moveTo(this.fogOfWarShapes[i].points[0].x, this.fogOfWarShapes[i].points[0].y); + for (let p = 1; p < this.fogOfWarShapes[i].points.length; p++){ + this.fogctx.lineTo(this.fogOfWarShapes[i].points[p].x, this.fogOfWarShapes[i].points[p].y); + } + this.fogctx.closePath(); + this.fogctx.fill(); + break; + case "rect": + const width = this.fogOfWarShapes[i].points[1].x - this.fogOfWarShapes[i].points[0].x; + const height = this.fogOfWarShapes[i].points[1].y - this.fogOfWarShapes[i].points[0].y + this.fogctx.rect(this.fogOfWarShapes[i].points[0].x, this.fogOfWarShapes[i].points[0].y, width, height); + this.fogctx.fill(); + break; + } + } + this.fogctx.globalCompositeOperation = "source-over"; + } + + private renderFogOfWar() { + if (!this.fogOfWar) return; + if (room.isGM) { + this.fogctx.globalAlpha = 0.6; + } + let color = "#fafafa" + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + color = "#09090b" + } + this.fogctx.fillStyle = color; + this.fogctx.fillRect(0, 0, this.w, this.h); + this.revealShapes(); + } + + private renderGridLines(){ + if (!this.renderGrid) return; + const columns = Math.ceil(this.w / this.gridSize); + const rows = Math.ceil(this.h / this.gridSize); + + this.imgctx.strokeStyle = "rgb(0,0,0)"; + for (let i = 0; i < columns; i++) { + const x = i * this.gridSize; + this.imgctx.beginPath(); + this.imgctx.moveTo(x, 0); + this.imgctx.lineTo(x, this.h); + this.imgctx.stroke(); + } + for (let i = 0; i < rows; i++) { + const y = i * this.gridSize; + this.imgctx.beginPath(); + this.imgctx.moveTo(0, y); + this.imgctx.lineTo(this.w, y); + this.imgctx.stroke(); + } + } + + public load(image: HTMLImageElement) { + if (!image) { + this.w = 0; + this.h = 0; + this.image = null; + + this.canvas.width = this.w; + this.canvas.height = this.h; + this.canvas.style.width = `0px`; + this.canvas.style.height = `0px`; + + this.fogCanvas.width = this.w; + this.fogCanvas.height = this.h; + this.fogCanvas.style.width = `0px`; + this.fogCanvas.style.height = `0px`; + } else { + this.w = image.width; + this.h = image.height; + this.image = image; + + this.canvas.width = this.w; + this.canvas.height = this.h; + this.canvas.style.width = `${image.width}px`; + this.canvas.style.height = `${image.height}px`; + + this.fogCanvas.width = this.w; + this.fogCanvas.height = this.h; + this.fogCanvas.style.width = `${image.width}px`; + this.fogCanvas.style.height = `${image.height}px`; + } + this.render(); + } + + override render(): void { + this.fogctx.clearRect(0, 0, this.w, this.h); + this.imgctx.clearRect(0, 0, this.w, this.h); + + if (!this.image) return; + + // Always draw map first + this.imgctx.drawImage(this.image, 0, 0, this.w, this.h); + + // Other + this.renderGridLines(); + + // Always draw fog last + this.renderFogOfWar(); + this.imgctx.drawImage(this.fogCanvas, 0, 0); + } +} +env.bind("table-canvas", TableCanvas); diff --git a/client/src/pages/tabletop-page/tabletop-component/tabletop-component.ts b/client/src/pages/tabletop-page/tabletop-component/tabletop-component.ts index 8c9bbbe..f7592d0 100644 --- a/client/src/pages/tabletop-page/tabletop-component/tabletop-component.ts +++ b/client/src/pages/tabletop-page/tabletop-component/tabletop-component.ts @@ -2,10 +2,9 @@ import { publish, subscribe } from "@codewithkyle/pubsub"; import SuperComponent from "@codewithkyle/supercomponent"; import env from "~brixi/controllers/env"; import Pawn from "~components/pawn/pawn"; -import VFXCanvas from "./vfx-canvas/vfx-canvas"; -import GridCanvas from "./grid-canvas/grid-canvas"; import room from "room"; -import FogCanvas from "./fog-canvas/fog-canvas"; +import TableCanvas from "./table-canvas/table-canvas"; +import VFXCanvas from "./vfx-canvas/vfx-canvas"; import DoodleCanvas from "./doodle-canvas/doodle-canvas"; interface ITabletopComponent { @@ -20,9 +19,8 @@ export default class TabeltopComponent extends SuperComponent { - this.gridCanvas.render(this.img); + this.canvas.load(this.img); this.vfxCanvas.render(this.img); - this.fogCanvas.render(this.img); this.doodleCanvas.render(this.img); - if (this.isNewImage){ - this.isNewImage = false; - this.fogCanvas.load(); - } } } this.style.transform = `matrix(${this.zoom}, 0, 0, ${this.zoom}, ${this.x}, ${this.y})`; diff --git a/client/src/pages/tabletop-page/tabletop-component/turn-timer/turn-timer.ts b/client/src/pages/tabletop-page/tabletop-component/turn-timer/turn-timer.ts index 2d8336f..ba90452 100644 --- a/client/src/pages/tabletop-page/tabletop-component/turn-timer/turn-timer.ts +++ b/client/src/pages/tabletop-page/tabletop-component/turn-timer/turn-timer.ts @@ -43,6 +43,8 @@ export default class TurnTimerComponent extends SuperComponent @@ -56,9 +57,10 @@ kind="text" color="white" icon="center" - tooltip="Measure" + tooltip="Measure (m)" shape="round" x-on:click="$dispatch('window:draw:close');$dispatch('window:fog:close');active = 'measure';$dispatch('tabletop:mode', { mode: 'measure' });" + x-on:keydown.m.window="$dispatch('window:draw:close');$dispatch('window:fog:close');active = 'measure';$dispatch('tabletop:mode', { mode: 'measure' });" > @@ -70,9 +72,10 @@ kind="text" color="white" icon="center" - tooltip="Fog" + tooltip="Fog (f)" shape="round" x-on:click="$dispatch('window:draw:close');$dispatch('window:fog');active = 'fog';$dispatch('tabletop:mode', { mode: 'lock' });" + x-on:keydown.f.window="$dispatch('window:draw:close');$dispatch('window:fog');active = 'fog';$dispatch('tabletop:mode', { mode: 'lock' });" > @@ -83,9 +86,10 @@ kind="text" color="white" icon="center" - tooltip="Draw" + tooltip="Draw (d)" shape="round" x-on:click="$dispatch('window:fog:close');$dispatch('window:draw');active = 'draw';$dispatch('tabletop:mode', { mode: 'lock' });" + x-on:keydown.d.window="$dispatch('window:fog:close');$dispatch('window:draw');active = 'draw';$dispatch('tabletop:mode', { mode: 'lock' });" > @@ -167,7 +171,7 @@

    diff --git a/server/views/stubs/tabletop/spotlight.html b/server/views/stubs/tabletop/spotlight.html index 071f25f..a0be178 100644 --- a/server/views/stubs/tabletop/spotlight.html +++ b/server/views/stubs/tabletop/spotlight.html @@ -7,5 +7,7 @@ hx-trigger="keyup changed delay:500ms" hx-target="#spotlight-search-results" autocomplete="off" + x-data="" + x-on:keydown.stop="" >
    diff --git a/server/views/stubs/windows/monsters.html b/server/views/stubs/windows/monsters.html index aaee238..0fcd6cf 100644 --- a/server/views/stubs/windows/monsters.html +++ b/server/views/stubs/windows/monsters.html @@ -4,6 +4,8 @@ style="flex:1;" data-name="search" data-icon='' + x-data="" + x-on:keydown.stop="" >