initial commit

This commit is contained in:
equippedcoding-master
2025-09-17 09:37:06 -05:00
parent 86108ca47e
commit e2c98790b2
55389 changed files with 6206730 additions and 0 deletions
@@ -0,0 +1,18 @@
{
"sourceType": "unambiguous",
"presets": [
[
"@babel/preset-env",
{
"targets": {
"chrome": 100,
"safari": 15,
"firefox": 91
}
}
],
"@babel/preset-typescript",
"@babel/preset-react"
],
"plugins": []
}
@@ -0,0 +1,34 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.storybook/utils/
# testing
/coverage
# production
/dist
/docs
/storybook
/types
/react/dist/
/react/types/
/styles/dist/
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
yarn.lock
# tools
.vscode
@@ -0,0 +1,42 @@
import path from 'path';
const config = {
stories: ['../src/**/*.stories.tsx'],
addons: ['@storybook/addon-essentials', '@storybook/addon-webpack5-compiler-swc'],
webpackFinal: async (config, { configType }) => {
config.module.rules = config.module.rules.filter((rule) => {
if (!rule.test) return true;
return !rule.test.test(".scss");
});
config.module.rules.unshift({
test: /\.s[ac]ss$/i,
use: [
'style-loader',
'css-loader',
{
loader: 'sass-loader',
options: {
sassOptions: {
includePaths: [
path.resolve(__dirname, '../src/scss')
]
}
}
}
]
});
return config;
},
framework: {
name: '@storybook/react-webpack5',
options: {}
},
docs: {}
};
export default config;
@@ -0,0 +1,24 @@
import '../src/scss/pcui-theme-green.scss';
const preview = {
parameters: {
backgrounds: {
default: 'playcanvas',
values: [
{
name: 'playcanvas',
value: '#374346'
},
{
name: 'white',
value: '#FFFFFF'
}
]
},
controls: { expanded: true }
},
tags: ['autodocs']
};
export default preview;
@@ -0,0 +1,16 @@
{
"extends": "stylelint-config-standard-scss",
"rules": {
"font-family-no-missing-generic-family-keyword": [
true,
{
"ignoreFontFamilies": [
"pc-icon"
]
}
],
"no-descending-specificity": null,
"no-duplicate-selectors": null,
"scss/at-extend-no-missing-placeholder": null
}
}
@@ -0,0 +1,77 @@
# Building a UMD Bundle
If you need a UMD version of the library (for example, to use it in a PlayCanvas Editor project), follow these steps:
1. Create a new folder and navigate to it in your terminal
2. Create a new NPM project:
```bash
npm init -y
```
3. Install the required packages:
```bash
npm install --save @babel/core babel-loader webpack webpack-cli @playcanvas/observer @playcanvas/pcui
```
4. Create `index.js` with the following content:
```js
import * as pcui from '@playcanvas/pcui';
import '@playcanvas/pcui/styles';
window.pcui = pcui;
```
5. Create `webpack.config.js`:
```js
const path = require('path');
module.exports = {
mode: 'production',
entry: './index.js',
output: {
path: path.resolve('dist'),
filename: 'pcui-bundle.min.js',
libraryTarget: 'window'
},
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
use: 'babel-loader'
}]
},
resolve: {
extensions: ['.js']
}
};
```
6. Add the build script to `package.json`:
```json
{
"scripts": {
"build": "webpack --mode production"
}
}
```
7. Build the bundle:
```bash
npm run build
```
The UMD bundle will be created in the `dist` folder as `pcui-bundle.min.js`. You can now use PCUI components through the global `pcui` object:
```js
const panel = new pcui.Panel({
flex: true,
collapsible: true,
headerText: 'Settings'
});
```
@@ -0,0 +1,19 @@
Copyright (c) 2011-2025 PlayCanvas Ltd.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,148 @@
# PCUI - User Interface Component Library for the Web
[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/playcanvas/pcui/blob/main/LICENSE)
[![NPM Version](https://img.shields.io/npm/v/@playcanvas/pcui.svg?style=flat?style=flat)](https://www.npmjs.com/package/@playcanvas/pcui)
[![NPM Downloads](https://img.shields.io/npm/dw/@playcanvas/pcui)](https://npmtrends.com/@playcanvas/pcui)
| [User Guide](https://developer.playcanvas.com/user-manual/pcui/) | [API Reference](https://api.playcanvas.com/modules/PCUI.html) | [ESM Examples](https://playcanvas.github.io/pcui/examples/) | [React Examples](https://playcanvas.github.io/pcui/storybook/) | [Blog](https://blog.playcanvas.com/) | [Forum](https://forum.playcanvas.com/) | [Discord](https://discord.gg/RSaMRzg) |
![PCUI Banner](https://forum-files-playcanvas-com.s3.dualstack.eu-west-1.amazonaws.com/original/2X/7/7e51de8ae69fa499dcad292efd21d7722dcf2dbd.jpeg)
This library enables the creation of reliable and visually pleasing user interfaces by providing fully styled components that you can use directly on your site. The components are useful in a wide range of use cases, from creating simple forms to building graphical user interfaces for complex web tools.
A full guide to using the PCUI library can be found [here](https://developer.playcanvas.com/user-manual/pcui/).
## Getting Started
To install the PCUI NPM module, run the following command:
npm install @playcanvas/pcui --save-dev
You can then import each individual element from PCUI. In the example below, you can see how the PCUI `Label` component is imported from the PCUI library. The styles for PCUI are then imported into the example. Styles only need to be imported once per project.
```javascript
import { Label } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles';
const label = new Label({
text: 'Hello World'
});
document.body.appendChild(label.dom);
```
If you'd like to include PCUI in your React project, you can import the individual components as follows:
```javascript
import * as React from 'react';
import ReactDOM from 'react-dom';
import { TextInput } from '@playcanvas/pcui/react';
import '@playcanvas/pcui/styles';
ReactDOM.render(
<TextInput text='Hello World'/>,
document.body
);
```
## Building a UMD bundle
If you need a UMD version of the PCUI library (say, to use it in a PlayCanvas Editor project), please refer to our [build guide](BUILDGUIDE.md).
## Fonts in PCUI
PCUI uses four CSS classes for fonts across its components: `.font-regular`, `.font-bold`, `.font-thin` and `.font-light`. By default, these use the Helvetica Neue font stack:
```css
font-family: 'Helvetica Neue', Arial, Helvetica, sans-serif;
```
### Using your own Font
You can override PCUI's default font by adding your own `font-family` CSS rules to these classes on your webpage:
```css
.font-regular, .font-bold, .font-thin, .font-light {
font-family: 'Your Font', sans-serif;
}
```
## Data Binding
The PCUI library offers a data binding layer that can be used to synchronize data across multiple components. It offers two way binding to a given observer object, so updates made in a component are reflected in the observer's data and distributed out to all other subscribed components. A simple use case is shown below:
```javascript
import { Observer } from '@playcanvas/observer';
import { Label, TextInput, BindingObserversToElement, BindingElementToObservers } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles';
// create a new observer for a simple object which contains a text string
const observer = new Observer({
text: 'Hello World'
});
// create a label which will listen to updates from the observer
const label = new Label({
binding: new BindingObserversToElement()
});
// link the observer to the label, telling it to use the text variable as its value
label.link(observer, 'text');
// create a text input which will send updates to the observer
const textInput = new TextInput({
binding: new BindingElementToObservers()
});
// link the observer to the label, telling it to set the text variable on change
textInput.link(observer, 'text');
```
In the above example, the created label will start with `Hello World` as its text value. When a user enters a value into the text input, the label will be updated with the new value.
Observers can also be bound bi-directionally, in which case an element can both send and receive updates through its observer. The following example shows a two way binding between two text inputs, where either input can update the value of the other. It's been written in React to showcase binding with React components:
```jsx
import { Observer } from '@playcanvas/observer';
import { BindingTwoWay, TextInput } from '@playcanvas/pcui/react';
import '@playcanvas/pcui/styles';
// create a new observer for a simple object which contains a text string
const observer = new Observer({
text: 'Hello World'
});
// create two text inputs, which can both send and receive updates through the linked observer
const TextInput1 = () => <TextInput binding={new BindingTwoWay()} link={{ observer, path: 'text'} />;
const TextInput2 = () => <TextInput binding={new BindingTwoWay()} link={{ observer, path: 'text'} />;
```
## Development
Each component exists in its own folder within the `./src/components` directory. They each contain:
- `index.ts`: The PCUI element.
- `style.scss`: The SASS styles for the PCUI element.
- `component.tsx`: A React component wrapping the PCUI element.
- `component.stories.tsx`: The Storybook entry for the React component.
Locally developed components can be viewed & tested by running the Storybook app, as mentioned in the following section.
If you'd like to build your own custom version of the library you can run the `npm run build` command which will create a `dist` directory with your custom build.
## Storybook
If you wish to view all components available to you in the library, you can run a local version Storybook. It allows you to browse the entire collection of components and test any changes you make to them. Each component page also includes component documentation and allows you to test each component in all of its configuration states.
Run Storybook as follows:
npm install
npm run storybook
## API Documentation
To build the PCUI API Reference to the `docs` folder, do:
```bash
npm run docs
```
@@ -0,0 +1,41 @@
import playcanvasConfig from '@playcanvas/eslint-config';
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import globals from 'globals';
export default [
...playcanvasConfig,
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
project: true
},
globals: {
...globals.browser,
...globals.mocha,
...globals.node
}
},
plugins: {
'@typescript-eslint': tsPlugin
},
settings: {
'import/resolver': {
typescript: {}
}
},
rules: {
...tsPlugin.configs['recommended'].rules,
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'jsdoc/require-param-type': 'off',
'jsdoc/require-returns': 'off',
'jsdoc/require-returns-type': 'off'
}
}
];
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI ArrayInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { ArrayInput, LabelGroup } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const types = [ 'boolean', 'number', 'string', 'vec2', 'vec3', 'vec4' ];
for (const type of types) {
const arrayInput = new ArrayInput({
type: type
});
const labelGroup = new LabelGroup({
field: arrayInput,
text: type + ':'
});
document.body.appendChild(labelGroup.dom);
}
</script>
</body>
</html>
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI BooleanInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { BooleanInput, LabelGroup } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const checkbox = new BooleanInput();
const checkboxGroup = new LabelGroup({
field: checkbox,
text: 'Checkbox:'
});
document.body.appendChild(checkboxGroup.dom);
const toggle = new BooleanInput({
type: 'toggle'
});
const toggleGroup = new LabelGroup({
field: toggle,
text: 'Toggle:'
});
document.body.appendChild(toggleGroup.dom);
</script>
</body>
</html>
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Button</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Button } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const button = new Button({
icon: 'E400',
text: 'Click Me'
});
document.body.appendChild(button.dom);
</script>
</body>
</html>
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Canvas</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Canvas } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const canvas = new Canvas({
useDevicePixelRatio: true
});
canvas.resize(512, 512);
// draw a smiling face with the 2D canvas API
const ctx = canvas.dom.getContext('2d');
// face
ctx.beginPath();
ctx.arc(256, 256, 200, 0, 2 * Math.PI);
ctx.fillStyle = 'yellow';
ctx.fill();
ctx.lineWidth = 10;
ctx.strokeStyle = 'black';
ctx.stroke();
// left eye
ctx.beginPath();
ctx.arc(200, 200, 50, 0, 2 * Math.PI);
ctx.fillStyle = 'black';
ctx.fill();
// right eye
ctx.beginPath();
ctx.arc(312, 200, 50, 0, 2 * Math.PI);
ctx.fillStyle = 'black';
ctx.fill();
// mouth
ctx.beginPath();
ctx.arc(256, 256, 100, 0, Math.PI);
ctx.stroke();
document.body.appendChild(canvas.dom);
</script>
</body>
</html>
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Code</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Code } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
// get the code for this example
const src = document.querySelector('script[type="module"]').innerHTML;
const code = new Code({
text: src
});
document.body.appendChild(code.dom);
</script>
</body>
</html>
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI ColorPicker</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
.pcui-label-group > .pcui-label:first-child {
font-size: 16px;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { ColorPicker, LabelGroup } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const rgb = new ColorPicker();
const rgbGroup = new LabelGroup({
field: rgb,
text: 'RGB:'
});
const rgba = new ColorPicker({
channels: 4
});
const rgbaGroup = new LabelGroup({
field: rgba,
text: 'RGB + Alpha:'
});
document.body.appendChild(rgbGroup.dom);
document.body.appendChild(rgbaGroup.dom);
</script>
</body>
</html>
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Divider</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Divider, LabelGroup, TextInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const aboveGroup = new LabelGroup({
field: new TextInput(),
text: 'Above Divider'
});
document.body.appendChild(aboveGroup.dom);
const divider = new Divider();
document.body.appendChild(divider.dom);
const belowGroup = new LabelGroup({
field: new TextInput(),
text: 'Below Divider'
});
document.body.appendChild(belowGroup.dom);
</script>
</body>
</html>
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI GradientPicker</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { GradientPicker } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const gradientPicker = new GradientPicker();
document.body.appendChild(gradientPicker.dom);
</script>
</body>
</html>
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI GridView</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { GridView, GridViewItem } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const gridView = new GridView();
for (let i = 1; i <= 100; i++) {
const item = new GridViewItem({
text: `Item ${i}`
});
gridView.append(item);
}
document.body.appendChild(gridView.dom);
</script>
</body>
</html>
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI InfoBox</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { InfoBox } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const info = new InfoBox({
unsafe: true,
icon: 'E400',
text: 'An info box can be used to display information to the user. It can contain a title, icon and text. You can even include HTML such as <a href="https://github.com/playcanvas/pcui" target="_none">hyperlinks</a> if you set the unsafe property of the info box to true!',
title: 'Info Box'
});
document.body.appendChild(info.dom);
</script>
</body>
</html>
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Label</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Label } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const label = new Label({
text: 'This is a Label'
});
document.body.appendChild(label.dom);
</script>
</body>
</html>
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI LabelGroup</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { LabelGroup, TextInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
for (let i = 1; i <= 10; i++) {
const labelGroup = new LabelGroup({
text: 'Label ' + i,
field: new TextInput()
})
document.body.appendChild(labelGroup.dom);
}
</script>
</body>
</html>
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Menu</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Label, Menu } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const label = new Label({
text: 'Right click to open menu'
});
document.body.appendChild(label.dom);
const menu = new Menu({
items: [
{
text: 'Item 1'
},
{
text: 'Item 2'
},
{
text: 'Item 3',
items: [
{
text: 'Item 3.1'
},
{
text: 'Item 3.2'
},
{
text: 'Item 3.3'
}
]
}
]
});
document.body.appendChild(menu.dom);
window.addEventListener('contextmenu', (event) => {
event.stopPropagation();
event.preventDefault();
menu.hidden = false;
menu.position(event.clientX, event.clientY);
});
</script>
</body>
</html>
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI NumericInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
.pcui-element.pcui-label-group > .pcui-label:first-child {
width: 125px;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { InfoBox, LabelGroup, NumericInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const numericInput = new NumericInput();
const labelGroup = new LabelGroup({
field: numericInput,
text: 'Enter a number:'
});
const infoBox = new InfoBox({
icon: 'E400',
text: 'NumericInput also supports expressions. For example, you can enter "10 * (2 + 3)" to get a value of 50.'
});
document.body.appendChild(labelGroup.dom);
document.body.appendChild(infoBox.dom);
</script>
</body>
</html>
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Overlay</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
.pcui-overlay-content {
z-index: 0;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Button, Label, Overlay } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const overlay = new Overlay();
const label = new Label({
class: 'text',
text: 'Dismiss for 1 second?'
});
overlay.append(label);
const btnYes = new Button({
text: 'Yes'
});
btnYes.on('click', () => {
overlay.hidden = true;
setTimeout(() => {
overlay.hidden = false;
}, 1000);
});
overlay.append(btnYes);
document.body.appendChild(overlay.dom);
</script>
</body>
</html>
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Panel</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { LabelGroup, Panel, TextInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const panel = new Panel({
collapseHorizontally: false,
collapsible: true,
collapsed: false,
headerText: 'Panel Header Text (click to collapse)'
});
for (let i = 1; i <= 10; i++) {
const labelGroup = new LabelGroup({
text: 'Property ' + i,
field: new TextInput()
})
panel.append(labelGroup);
}
document.body.appendChild(panel.dom);
</script>
</body>
</html>
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Progress</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Progress } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const progress = new Progress({
value: 0
});
setInterval(() => {
progress.value++;
if (progress.value === 100) {
progress.value = 0;
}
}, 20);
document.body.appendChild(progress.dom);
</script>
</body>
</html>
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI RadioButton</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { LabelGroup, RadioButton } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const radioButtons = [];
for (let i = 0; i < 5; i++) {
const radioButton = new RadioButton();
radioButton.on('click', () => {
radioButtons.forEach(radioButton => {
radioButton.value = false;
});
radioButton.value = true;
});
radioButtons.push(radioButton);
const group = new LabelGroup({
text: `Option ${i + 1}`,
field: radioButton
});
document.body.appendChild(group.dom);
}
radioButtons[0].value = true;
</script>
</body>
</html>
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI SelectInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
.pcui-element.pcui-label-group > .pcui-label:first-child {
width: 175px;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { LabelGroup, SelectInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const select = new SelectInput({
options: [
{ t: 'Option 1', v: 1 },
{ t: 'Option 2', v: 2 },
{ t: 'Option 3', v: 3 },
{ t: 'Default', v: 4 }
],
defaultValue: 4,
type: 'number'
});
const labelGroup1 = new LabelGroup({
text: 'SelectInput:',
field: select
});
const selectWithFilter = new SelectInput({
allowInput: true,
options: [
{ t: 'Option 1', v: 1 },
{ t: 'Option 2', v: 2 },
{ t: 'Option 3', v: 3 },
{ t: 'Default', v: 4 }
],
defaultValue: 4,
type: 'number'
});
const labelGroup2 = new LabelGroup({
text: 'SelectInput with Filter:',
field: selectWithFilter
});
document.body.appendChild(labelGroup1.dom);
document.body.appendChild(labelGroup2.dom);
</script>
</body>
</html>
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI SliderInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { SliderInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const sliderInput = new SliderInput();
document.body.appendChild(sliderInput.dom);
</script>
</body>
</html>
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Spinner</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Spinner } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const spinner = new Spinner();
document.body.appendChild(spinner.dom);
</script>
</body>
</html>
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI TextAreaInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { TextAreaInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const textAreaInput1 = new TextAreaInput({
readOnly: true,
value: 'This is a read-only, fixed size TextAreaInput.'
});
document.body.appendChild(textAreaInput1.dom);
const textAreaInput2 = new TextAreaInput({
resizable: 'vertical',
width: 500,
value: 'This is an editable, resizable TextAreaInput. Try dragging the resize handle. The element also supports Shift-Enter for new lines.'
});
document.body.appendChild(textAreaInput2.dom);
</script>
</body>
</html>
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI TextInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { TextInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const textInput = new TextInput({
value: 'Enter text here...'
});
document.body.appendChild(textInput.dom);
</script>
</body>
</html>
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI TreeView</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
.pcui-treeview-item-text,
.pcui-treeview-item-icon {
font-size: 14px;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { TreeView, TreeViewItem } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const treeView = new TreeView({
allowRenaming: true
});
const root = new TreeViewItem({
open: true,
text: 'My Drive',
icon: 'E229' // Drive icon
});
treeView.append(root);
const generateChildren = (parent, depth) => {
if (depth > 0) {
for (let i = 0; i < 5; i++) {
const isFolder = depth > 1;
const item = new TreeViewItem({
allowDrop: isFolder,
text: (isFolder ? 'Folder ' : 'File ') + i,
icon: isFolder ? 'E139' : 'E208' // Folder or file icon
});
parent.append(item);
generateChildren(item, depth - 1);
}
}
};
generateChildren(root, 3);
document.body.appendChild(treeView.dom);
</script>
</body>
</html>
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI VectorInput</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { LabelGroup, VectorInput } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
for (let i = 2; i <= 4; i++) {
const group = new LabelGroup({
text: `${i}D Vector`,
field: new VectorInput({
dimensions: i,
min: 0,
max: 1,
placeholder: ['X', 'Y', 'Z', 'W'].slice(0, i),
precision: 4,
step: 0.01
})
});
document.body.appendChild(group.dom);
}
</script>
</body>
</html>
@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI Example Browser</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body, html {
background-color: #364346;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
iframe {
flex-grow: 1;
border: 2px solid rgb(175, 175, 175);
}
.pcui-treeview-item-text,
.pcui-treeview-item-icon {
font-size: 14px;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { Container, Panel, TextInput, TreeView, TreeViewItem } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const root = new Container({
flex: true,
flexDirection: 'row',
width: '100%',
height: '100%'
});
document.body.appendChild(root.dom);
const panel = new Panel({
collapseHorizontally: true,
collapsible: true,
scrollable: true,
headerText: 'PCUI Example Browser',
width: 200,
height: '100%'
});
const treeView = new TreeView({
allowRenaming: false,
allowReordering: false,
allowDrag: false
});
const iframe = document.createElement('iframe');
const categories = [
{
categoryName: 'Elements',
examples: [
'ArrayInput',
'BooleanInput',
'Button',
'Canvas',
'Code',
'ColorPicker',
'Divider',
'GradientPicker',
'GridView',
'InfoBox',
'Label',
'LabelGroup',
'Menu',
'NumericInput',
'Overlay',
'Panel',
'Progress',
'RadioButton',
'SelectInput',
'SliderInput',
'Spinner',
'TextAreaInput',
'TextInput',
'TreeView',
'VectorInput'
]
},
{
categoryName: 'Utilities',
examples: [
'Icon Browser',
]
}
];
for (const category of categories) {
const categoryItem = new TreeViewItem({
open: true,
text: category.categoryName
});
treeView.append(categoryItem);
for (const example of category.examples) {
const item = new TreeViewItem({
text: example
});
item.on('select', () => {
const path = category.categoryName.toLowerCase();
const name = example.toLowerCase().replace(/ /g, '-');
iframe.src = `${path}/${name}.html`;
});
categoryItem.append(item);
}
}
const filter = new TextInput({
keyChange: true,
placeholder: 'Filter',
width: 'calc(100% - 14px)'
});
filter.on('change', (value) => {
treeView.filter = value;
});
root.append(panel);
panel.append(filter);
panel.append(treeView);
root.dom.appendChild(iframe);
</script>
</body>
</html>
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PCUI GridView</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: #364346;
}
.icon-info > .pcui-label {
font-size: 16px;
line-height: 2;
}
.pcui-gridview-item-text::before {
font-family: pc-icon;
content: attr(data-before);
padding-right: 10px;
}
</style>
<script type="importmap">
{
"imports": {
"@playcanvas/observer": "../../node_modules/@playcanvas/observer/dist/index.mjs",
"@playcanvas/pcui": "../../dist/module/src/index.mjs",
"@playcanvas/pcui/styles": "../../styles/dist/index.mjs"
}
}
</script>
</head>
<body>
<script type="module">
import { GridView, GridViewItem } from '@playcanvas/pcui';
import '@playcanvas/pcui/styles'
const gridView = new GridView({
multiSelect: false
});
const ranges = [
[ 111, 392 ],
[ 394, 415 ],
[ 421, 426 ],
[ 430, 439 ]
];
for (const range of ranges) {
for (let i = range[0]; i <= range[1]; i++) {
const item = new GridViewItem({
class: 'icon-info',
text: `E${i}`
});
const span = item.dom.childNodes[0];
const icon = String.fromCodePoint(parseInt(`E${i}`, 16));
span.setAttribute('data-before', icon);
gridView.append(item);
}
}
document.body.appendChild(gridView.dom);
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
{
"name": "@playcanvas/pcui",
"version": "5.1.0",
"author": "PlayCanvas <support@playcanvas.com>",
"homepage": "https://playcanvas.github.io/pcui",
"description": "User interface component library for the web",
"keywords": [
"components",
"css",
"dom",
"html",
"javascript",
"pcui",
"playcanvas",
"react",
"sass",
"typescript",
"ui"
],
"license": "MIT",
"main": "dist/module/src/index.mjs",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/module/src/index.mjs"
},
"./react": {
"types": "./react/types/index.d.ts",
"import": "./react/dist/module/src/index.mjs"
},
"./styles": {
"import": "./styles/dist/index.mjs"
}
},
"type": "module",
"bugs": {
"url": "https://github.com/playcanvas/pcui/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/playcanvas/pcui.git"
},
"files": [
"dist",
"types",
"react/dist",
"react/types",
"react/package.json",
"styles"
],
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"dependencies": {
"@playcanvas/observer": "^1.5.1"
},
"devDependencies": {
"@babel/preset-env": "7.26.8",
"@babel/preset-react": "7.26.3",
"@babel/preset-typescript": "7.26.0",
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
"@playcanvas/eslint-config": "2.0.9",
"@rollup/plugin-node-resolve": "16.0.0",
"@rollup/plugin-replace": "6.0.2",
"@rollup/plugin-typescript": "12.1.2",
"@storybook/addon-essentials": "8.5.4",
"@storybook/addon-webpack5-compiler-swc": "2.0.0",
"@storybook/react": "8.5.4",
"@storybook/react-webpack5": "8.5.4",
"@types/estree": "1.0.6",
"@types/react": "19.0.8",
"@types/react-dom": "19.0.3",
"@typescript-eslint/eslint-plugin": "8.24.0",
"@typescript-eslint/parser": "8.24.0",
"autoprefixer": "10.4.20",
"concurrently": "9.1.2",
"cross-env": "7.0.3",
"css-loader": "7.1.2",
"eslint": "9.20.1",
"eslint-import-resolver-typescript": "3.7.0",
"global-jsdom": "26.0.0",
"globals": "15.14.0",
"jsdom": "26.0.0",
"playcanvas": "2.5.0",
"postcss": "8.5.2",
"prop-types": "15.8.1",
"publint": "0.3.4",
"rollup": "4.34.6",
"rollup-plugin-sass": "1.15.2",
"sass-loader": "16.0.4",
"scss-bundle": "3.1.2",
"serve": "14.2.4",
"storybook": "8.5.4",
"style-loader": "4.0.0",
"stylelint": "16.14.1",
"stylelint-config-standard-scss": "14.0.0",
"typedoc": "0.27.7",
"typedoc-plugin-mdn-links": "4.0.12",
"typescript": "5.7.3"
},
"peerDependencies": {
"react": "^18.3.1 || ^19.0.0",
"react-dom": "^18.3.1 || ^19.0.0"
},
"scripts": {
"build": "rollup -c && npm run bundle:styles",
"build:es6": "rollup -c --environment target:es6",
"build:icons": "cd ./utils && node ./build-font-icons.mjs",
"build:react:es6": "rollup -c --environment target:react:es6",
"build:storybook": "cross-env ENVIRONMENT=production storybook build -o storybook",
"build:styles": "rollup -c --environment target:styles",
"build:types": "tsc --project ./tsconfig.json --declaration --emitDeclarationOnly --outDir types && tsc --project ./react/tsconfig.json --declaration --emitDeclarationOnly --outDir ./react/types",
"bundle:styles": "scss-bundle -e ./src/scss/pcui-theme-grey.scss -o ./dist/pcui-theme-grey.scss && scss-bundle -e ./src/scss/pcui-theme-green.scss -o ./dist/pcui-theme-green.scss",
"develop": "cross-env concurrently --kill-others \"npm run watch\" \"npm run serve\"",
"docs": "typedoc",
"lint": "eslint src",
"lint:package": "publint",
"lint:styles": "stylelint src/**/*.scss",
"serve": "serve",
"storybook": "storybook dev -p 9000",
"test": "node --test",
"watch": "rollup -c -w",
"watch:es6": "rollup -c -w --environment target:es6",
"watch:react:es6": "rollup -c -w --environment target:react:es6",
"watch:styles": "rollup -c -w --environment target:styles"
},
"engines": {
"node": ">=18.0.0"
}
}
@@ -0,0 +1,18 @@
{
"name": "pcui-react",
"version": "1.0.0",
"author": "PlayCanvas <support@playcanvas.com>",
"homepage": "https://playcanvas.github.io/pcui",
"description": "PCUI components for react",
"private": true,
"main": "dist/module/src/index.mjs",
"types": "types/index.d.ts",
"license": "MIT",
"bugs": {
"url": "https://github.com/playcanvas/pcui/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/playcanvas/pcui.git"
}
}
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"noImplicitAny": true,
"allowJs": true,
"target": "es6",
"jsx": "react",
"types": ["react", "react-dom", "webxr"],
"lib": [
"es2019",
"dom",
"dom.iterable"
],
"esModuleInterop": true,
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["../src/index.tsx"],
"exclude": ["node_modules/**/*", "node_modules"]
}
@@ -0,0 +1,118 @@
import fs from 'fs';
import { execSync } from 'child_process';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import typescript from '@rollup/plugin-typescript';
import autoprefixer from 'autoprefixer';
import postcss from 'postcss';
import sass from 'rollup-plugin-sass';
/**
* @returns {string} Version string like `1.58.0-dev`
*/
const getVersion = () => {
const text = fs.readFileSync('./package.json', 'utf8');
const json = JSON.parse(text);
return json.version;
}
/**
* @returns {string} Revision string like `644d08d39` (9 digits/chars).
*/
const getRevision = () => {
let revision;
try {
revision = execSync('git rev-parse --short HEAD').toString().trim();
} catch (e) {
revision = 'unknown';
}
return revision;
}
const replacements = {
values: {
'PACKAGE_VERSION': getVersion(),
'PACKAGE_REVISION': getRevision()
},
preventAssignment: true
};
const module = {
input: 'src/index.ts',
external: ['@playcanvas/observer'],
output: {
dir: `dist/module`,
entryFileNames: '[name].mjs',
format: 'esm',
preserveModules: true,
sourcemap: true
},
plugins: [
nodeResolve(),
replace(replacements),
typescript({
noEmitOnError: true,
tsconfig: 'tsconfig.json',
sourceMap: true
})
],
treeshake: 'smallest',
cache: false
};
const react_module = {
input: 'src/index.tsx',
external: ['@playcanvas/observer', 'react', 'prop-types'],
output: {
dir: `react/dist/module`,
format: 'esm',
entryFileNames: '[name].mjs',
globals: {
'react': 'React'
},
preserveModules: true,
sourcemap: true
},
plugins: [
nodeResolve(),
replace(replacements),
typescript({
noEmitOnError: true,
tsconfig: 'react/tsconfig.json',
sourceMap: true
})
],
treeshake: 'smallest',
cache: false
};
const styles = {
input: 'src/scss/index.js',
output: {
file: 'styles/dist/index.mjs',
format: 'esm'
},
plugins: [
nodeResolve(),
sass({
insert: true,
output: false,
processor: css => postcss([autoprefixer])
.process(css, {
from: undefined
})
.then(result => result.css)
})
]
}
export default (args) => {
if (process.env.target === 'es6') {
return [module];
} else if (process.env.target === 'react:es6') {
return [react_module];
} else if (process.env.target === 'styles') {
return [styles];
}
return [module, react_module, styles];
};
@@ -0,0 +1,82 @@
function _rgb2hsv(rgb: Array<number>) {
const r = rgb[0] / 255,
g = rgb[1] / 255,
b = rgb[2] / 255;
let h, s;
const v = Math.max(r, g, b);
const diff = v - Math.min(r, g, b);
const diffc = (c: number) => (v - c) / 6 / diff + 1 / 2;
if (diff === 0) {
h = s = 0;
return [h, s, v];
}
s = diff / v;
const rr = diffc(r);
const gg = diffc(g);
const bb = diffc(b);
if (r === v) {
h = bb - gg;
} else if (g === v) {
h = (1 / 3) + rr - bb;
} else if (b === v) {
h = (2 / 3) + gg - rr;
}
if (h < 0) {
h += 1;
} else if (h > 1) {
h -= 1;
}
return [h, s, v];
}
function _hsv2rgb(hsv: number[]) {
const h = hsv[0];
const s = hsv[1];
const v = hsv[2];
let r, g, b;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0:
r = v;
g = t;
b = p;
break;
case 1:
r = q;
g = v;
b = p;
break;
case 2:
r = p;
g = v;
b = t;
break;
case 3:
r = p;
g = q;
b = v;
break;
case 4:
r = t;
g = p;
b = v;
break;
case 5:
r = v;
g = p;
b = q;
break;
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
export { _hsv2rgb, _rgb2hsv };
@@ -0,0 +1,295 @@
import { Events, History, Observer } from '@playcanvas/observer';
import { IBindable } from '../../components/Element';
/**
* The interface for arguments for the {@link BindingBase} constructor.
*/
export interface BindingBaseArgs {
/**
* The IBindable element.
*/
element?: IBindable,
/**
* The history object which will be used to record undo / redo actions.
* If none is provided then no history will be recorded.
*/
history?: History,
/**
* A prefix that will be used for the name of every history action.
*/
historyPrefix?: string,
/**
* A postfix that will be used for the name of every history action.
*/
historyPostfix?: string,
/**
* The name of each history action.
*/
historyName?: string,
/**
* Whether to combine history actions.
*/
historyCombine?: boolean
}
/**
* Base class for data binding between {@link IBindable} {@link Element}s and Observers.
*/
class BindingBase extends Events {
protected _observers: Observer[] = [];
protected _paths: string[] = [];
protected _applyingChange = false;
protected _element?: IBindable;
protected _history?: History;
protected _historyPrefix?: string;
protected _historyPostfix?: string;
protected _historyName?: string;
protected _historyCombine: boolean;
protected _linked = false;
/**
* Creates a new binding.
*
* @param args - The arguments.
*/
constructor(args: Readonly<BindingBaseArgs>) {
super();
this._element = args.element;
this._history = args.history;
this._historyPrefix = args.historyPrefix;
this._historyPostfix = args.historyPostfix;
this._historyName = args.historyName;
this._historyCombine = args.historyCombine ?? false;
}
// Returns the path at the specified index
// or the path at the first index if it doesn't exist.
protected _pathAt(paths: string[], index: number) {
return paths[index] || paths[0];
}
/**
* Links the specified observers to the specified paths.
*
* @param observers - The observer(s).
* @param paths - The path(s). The behavior of the binding depends on how many paths are passed.
* If an equal amount of paths and observers are passed then the binding will map each path to each observer at each index.
* If more observers than paths are passed then the path at index 0 will be used for all observers.
* If one observer and multiple paths are passed then all of the paths will be used for the observer (e.g. for curves).
*/
link(observers: Observer|Observer[], paths: string|string[]) {
if (this._observers) {
this.unlink();
}
this._observers = Array.isArray(observers) ? observers : [observers];
this._paths = Array.isArray(paths) ? paths : [paths];
this._linked = true;
}
/**
* Unlinks the observers and paths.
*/
unlink() {
this._observers = [];
this._paths = [];
this._linked = false;
}
/**
* Clones the binding. To be implemented by derived classes.
*/
clone(): BindingBase {
throw new Error('BindingBase#clone: Not implemented');
}
/**
* Sets a value to the linked observers at the linked paths.
*
* @param value - The value
*/
setValue(value: any) {
}
/**
* Sets an array of values to the linked observers at the linked paths.
*
* @param values - The values.
*/
setValues(values: any[]) {
}
/**
* Adds (inserts) a value to the linked observers at the linked paths.
*
* @param value - The value.
*/
addValue(value: any) {
}
/**
* Adds (inserts) multiple values to the linked observers at the linked paths.
*
* @param values - The values.
*/
addValues(values: any[]) {
}
/**
* Removes a value from the linked observers at the linked paths.
*
* @param value - The value.
*/
removeValue(value: any) {
}
/**
* Removes multiple values from the linked observers from the linked paths.
*
* @param values - The values.
*/
removeValues(values: any[]) {
}
/**
* Sets the element.
*/
set element(value: IBindable | undefined) {
this._element = value;
}
/**
* Gets the element.
*/
get element(): IBindable | undefined {
return this._element;
}
/**
* Sets whether the binding is currently applying a change, either to the observers or the element.
*/
set applyingChange(value) {
if (this._applyingChange === value) return;
this._applyingChange = value;
this.emit('applyingChange', value);
}
/**
* Gets whether the binding is currently applying a change, either to the observers or the element.
*/
get applyingChange() {
return this._applyingChange;
}
/**
* Gets whether the binding is linked to observers.
*/
get linked() {
return this._linked;
}
/**
* Sets whether to combine history actions when applying changes to observers. This is assuming
* a history module is being used.
*/
set historyCombine(value) {
this._historyCombine = value;
}
/**
* Gets whether to combine history actions when applying changes to observers.
*/
get historyCombine() {
return this._historyCombine;
}
/**
* Sets the name of the history action when applying changes to observers.
*/
set historyName(value) {
this._historyName = value;
}
/**
* Gets the name of the history action when applying changes to observers.
*/
get historyName() {
return this._historyName;
}
/**
* Sets the string to prefix {@link historyName} with.
*/
set historyPrefix(value) {
this._historyPrefix = value;
}
/**
* Gets the string to prefix {@link historyName} with.
*/
get historyPrefix() {
return this._historyPrefix;
}
/**
* Sets the string to postfix {@link historyName} with.
*/
set historyPostfix(value) {
this._historyPostfix = value;
}
/**
* Gets the string to postfix {@link historyName} with.
*/
get historyPostfix() {
return this._historyPostfix;
}
/**
* Sets whether history is enabled for the binding. A valid history object must have been provided first.
*/
set historyEnabled(value) {
if (this._history) {
// @ts-ignore
this._history.enabled = value;
}
}
/**
* Gets whether history is enabled for the binding.
*/
get historyEnabled() {
// @ts-ignore
return this._history && this._history.enabled;
}
/**
* Gets the linked observers.
*/
get observers() {
return this._observers;
}
/**
* Gets the linked paths.
*/
get paths() {
return this._paths;
}
}
export { BindingBase };
@@ -0,0 +1,344 @@
import { Observer } from '@playcanvas/observer';
import { BindingBase } from '../BindingBase';
/**
* Provides one way binding between an {@link IBindable} element and Observers. Any changes from
* the element will be propagated to the observers.
*/
class BindingElementToObservers extends BindingBase {
/**
* Clone the binding and return a new instance.
*/
clone() {
return new BindingElementToObservers({
history: this._history,
historyPrefix: this._historyPrefix,
historyPostfix: this._historyPostfix,
historyName: this._historyName,
historyCombine: this._historyCombine
});
}
private _getHistoryActionName(paths: string[]) {
return `${this._historyPrefix || ''}${this._historyName || paths[0]}${this._historyPostfix || ''}`;
}
// Sets the value (or values of isArrayOfValues is true)
// to the observers
private _setValue(value: any, isArrayOfValues: boolean) {
if (this.applyingChange) return;
if (!this._observers.length) return;
this.applyingChange = true;
// make copy of observers if we are using history
// so that we can undo on the same observers in the future
const observers = this._observers.slice();
const paths = this._paths.slice();
const context = {
observers,
paths
};
const execute = () => {
this._setValueToObservers(observers, paths, value, isArrayOfValues);
this.emit('history:redo', context);
};
if (this._history) {
let previousValues: any[] = [];
if (observers.length === 1 && paths.length > 1) {
previousValues = paths.map((path) => {
return observers[0].has(path) ? observers[0].get(path) : undefined;
});
} else {
previousValues = observers.map((observer, i) => {
const path = this._pathAt(paths, i);
return observer.has(path) ? observer.get(path) : undefined;
});
}
this.emit('history:init', context);
this._history.add({
name: this._getHistoryActionName(paths),
redo: execute,
combine: this._historyCombine,
undo: () => {
this._setValueToObservers(observers, paths, previousValues, true);
this.emit('history:undo', context);
}
});
}
execute();
this.applyingChange = false;
}
private _setValueToObservers(observers: Observer[], paths: string[], value: any, isArrayOfValues: boolean) {
// special case for 1 observer with multiple paths (like curves)
// in that case set each value for each path
if (observers.length === 1 && paths.length > 1) {
for (let i = 0; i < paths.length; i++) {
const latest: any = observers[0].latest();
if (!latest) continue;
let history = false;
if (latest.history) {
history = latest.history.enabled;
latest.history.enabled = false;
}
const path = paths[i];
const val = value[i];
if (value !== undefined) {
this._observerSet(latest, path, val);
} else {
latest.unset(path);
}
if (history) {
latest.history.enabled = true;
}
}
return;
}
for (let i = 0; i < observers.length; i++) {
const latest: any = observers[i].latest();
if (!latest) continue;
let history = false;
if (latest.history) {
history = latest.history.enabled;
latest.history.enabled = false;
}
const path = this._pathAt(paths, i);
const val = isArrayOfValues ? value[i] : value;
if (value !== undefined) {
this._observerSet(latest, path, val);
} else {
latest.unset(path);
}
if (history) {
latest.history.enabled = true;
}
}
}
// Handles setting a value to an observer
// in case that value is an array
private _observerSet(observer: Observer, path: string, value: any) {
// check if the parent of the last field in the path
// exists in the observer because if it doesn't
// an error is most likely going to be raised by C3
const lastIndexDot = path.lastIndexOf('.');
if (lastIndexDot > 0 && !observer.has(path.substring(0, lastIndexDot))) {
return;
}
const isArray = Array.isArray(value);
// we need to slice an array value before passing it to the 'set'
// method otherwise there are cases where the Observer will be modifying
// the same array instance
observer.set(path, isArray && value ? value.slice() : value);
}
private _addValues(values: any[]) {
if (this.applyingChange) return;
if (!this._observers) return;
this.applyingChange = true;
// make copy of observers if we are using history
// so that we can undo on the same observers in the future
const observers = this._observers.slice();
const paths = this._paths.slice();
const records: any[] = [];
for (let i = 0; i < observers.length; i++) {
const path = this._pathAt(paths, i);
const observer = observers[i];
values.forEach((value) => {
if (observer.get(path).indexOf(value) === -1) {
records.push({
observer: observer,
path: path,
value: value
});
}
});
}
const execute = () => {
for (const record of records) {
const latest = record.observer.latest();
if (!latest) continue;
const path = record.path;
let history = false;
if (latest.history) {
history = latest.history.enabled;
latest.history.enabled = false;
}
latest.insert(path, record.value);
if (history) {
latest.history.enabled = true;
}
}
};
if (this._history && records.length) {
this._history.add({
name: this._getHistoryActionName(paths),
redo: execute,
combine: this._historyCombine,
undo: () => {
for (const record of records) {
const latest = record.observer.latest();
if (!latest) continue;
const path = record.path;
let history = false;
if (latest.history) {
history = latest.history.enabled;
latest.history.enabled = false;
}
latest.removeValue(path, record.value);
if (history) {
latest.history.enabled = true;
}
}
}
});
}
execute();
this.applyingChange = false;
}
private _removeValues(values: any[]) {
if (this.applyingChange) return;
if (!this._observers) return;
this.applyingChange = true;
// make copy of observers if we are using history
// so that we can undo on the same observers in the future
const observers = this._observers.slice();
const paths = this._paths.slice();
const records: any[] = [];
for (let i = 0; i < observers.length; i++) {
const path = this._pathAt(paths, i);
const observer = observers[i];
values.forEach((value) => {
const ind = observer.get(path).indexOf(value);
if (ind !== -1) {
records.push({
observer: observer,
path: path,
value: value,
index: ind
});
}
});
}
const execute = () => {
for (const record of records) {
const latest = record.observer.latest();
if (!latest) continue;
const path = record.path;
let history = false;
if (latest.history) {
history = latest.history.enabled;
latest.history.enabled = false;
}
latest.removeValue(path, record.value);
if (history) {
latest.history.enabled = true;
}
}
};
if (this._history && records.length) {
this._history.add({
name: this._getHistoryActionName(paths),
redo: execute,
combine: this._historyCombine,
undo: () => {
for (const record of records) {
const latest = record.observer.latest();
if (!latest) continue;
const path = record.path;
let history = false;
if (latest.history) {
history = latest.history.enabled;
latest.history.enabled = false;
}
if (latest.get(path).indexOf(record.value) === -1) {
latest.insert(path, record.value, record.index);
}
if (history) {
latest.history.enabled = true;
}
}
}
});
}
execute();
this.applyingChange = false;
}
setValue(value: any) {
this._setValue(value, false);
}
setValues(values: any[]) {
// make sure we deep copy arrays because they will not be cloned when set to the observers
values = values.slice().map(val => (Array.isArray(val) ? val.slice() : val));
this._setValue(values, true);
}
addValue(value: any) {
this._addValues([value]);
}
addValues(values: any[]) {
this._addValues(values);
}
removeValue(value: any) {
this._removeValues([value]);
}
removeValues(values: any[]) {
this._removeValues(values);
}
}
export { BindingElementToObservers };
@@ -0,0 +1,138 @@
import { EventHandle, Observer } from '@playcanvas/observer';
import { IBindable } from '../../components';
import { BindingBase, BindingBaseArgs } from '../BindingBase';
/**
* The interface for arguments for the {@link BindingObserversToElement} constructor.
*/
export interface BindingObserversToElementArgs extends BindingBaseArgs {
/**
* Custom update function.
*/
customUpdate?: (element: IBindable, observers: Observer[], paths: string[]) => void;
}
/**
* Provides one way binding between Observers and an {@link IBindable} element and Observers. Any
* changes from the observers will be propagated to the element.
*/
class BindingObserversToElement extends BindingBase {
_customUpdate: (element: IBindable, observers: Observer[], paths: string[]) => void;
_eventHandles: EventHandle[] = [];
_updateTimeout: number = null;
/**
* Creates a new BindingObserversToElement instance.
*
* @param args - The arguments.
*/
constructor(args: Readonly<BindingObserversToElementArgs> = {}) {
super(args);
this._customUpdate = args.customUpdate;
}
private _linkObserver(observer: Observer, path: string) {
this._eventHandles.push(observer.on(`${path}:set`, this._deferUpdateElement));
this._eventHandles.push(observer.on(`${path}:unset`, this._deferUpdateElement));
this._eventHandles.push(observer.on(`${path}:insert`, this._deferUpdateElement));
this._eventHandles.push(observer.on(`${path}:remove`, this._deferUpdateElement));
}
private _deferUpdateElement = () => {
if (this.applyingChange) return;
this.applyingChange = true;
this._updateTimeout = window.setTimeout(() => {
this._updateElement();
});
};
private _updateElement() {
if (this._updateTimeout) {
window.clearTimeout(this._updateTimeout);
this._updateTimeout = null;
}
this._updateTimeout = null;
this.applyingChange = true;
if (!this._element) return;
if (typeof this._customUpdate === 'function') {
this._customUpdate(this._element, this._observers, this._paths);
} else if (this._observers.length === 1) {
if (this._paths.length > 1) {
// if using multiple paths for the single observer (e.g. curves)
// then return an array of values for each path
this._element.value = this._paths.map((path) => {
return this._observers[0].has(path) ? this._observers[0].get(path) : undefined;
});
} else {
this._element.value = (this._observers[0].has(this._paths[0]) ? this._observers[0].get(this._paths[0]) : undefined);
}
} else {
this._element.values = this._observers.map((observer, i) => {
const path = this._pathAt(this._paths, i);
return observer.has(path) ? observer.get(path) : undefined;
});
}
this.applyingChange = false;
}
link(observers: Observer|Observer[], paths: string|string[]) {
super.link(observers, paths);
// don't render changes when we link
if (this._element) {
const renderChanges = this._element.renderChanges;
this._element.renderChanges = false;
this._updateElement();
this._element.renderChanges = renderChanges;
}
if (this._observers.length === 1) {
if (this._paths.length > 1) {
for (let i = 0; i < this._paths.length; i++) {
this._linkObserver(this._observers[0], this._paths[i]);
}
return;
}
}
for (let i = 0; i < this._observers.length; i++) {
this._linkObserver(this._observers[i], this._pathAt(this._paths, i));
}
}
/**
* Unlink the binding from its set of observers.
*/
unlink() {
for (const event of this._eventHandles) {
event.unbind();
}
this._eventHandles.length = 0;
if (this._updateTimeout) {
window.clearTimeout(this._updateTimeout);
this._updateTimeout = null;
}
super.unlink();
}
/**
* Clone the BindingObserversToElement instance.
*/
clone() {
return new BindingObserversToElement({
customUpdate: this._customUpdate
});
}
}
export { BindingObserversToElement };
@@ -0,0 +1,160 @@
import { Observer } from '@playcanvas/observer';
import { IBindable } from '../../components/Element';
import { BindingBase, BindingBaseArgs } from '../BindingBase';
import { BindingElementToObservers } from '../BindingElementToObservers';
import { BindingObserversToElement } from '../BindingObserversToElement';
/**
* The interface for arguments for the {@link BindingTwoWay} constructor.
*/
export interface BindingTwoWayArgs extends BindingBaseArgs {
/**
* BindingElementToObservers instance.
*/
bindingElementToObservers?: BindingElementToObservers;
/**
* BindingObserversToElement instance.
*/
bindingObserversToElement?: BindingObserversToElement;
}
/**
* Provides two way data binding between Observers and {@link IBindable} elements. This means that
* when the value of the Observers changes the IBindable will be updated and vice versa.
*/
class BindingTwoWay extends BindingBase {
_bindingElementToObservers: BindingElementToObservers;
_bindingObserversToElement: BindingObserversToElement;
/**
* Creates a new BindingTwoWay instance.
*
* @param args - The arguments.
*/
constructor(args: Readonly<BindingTwoWayArgs> = {}) {
super(args);
this._bindingElementToObservers = args.bindingElementToObservers ?? new BindingElementToObservers(args);
this._bindingObserversToElement = args.bindingObserversToElement ?? new BindingObserversToElement(args);
this._bindingElementToObservers.on('applyingChange', (value: any) => {
this.applyingChange = value;
});
this._bindingElementToObservers.on('history:init', (context: any) => {
this.emit('history:init', context);
});
this._bindingElementToObservers.on('history:undo', (context: any) => {
this.emit('history:undo', context);
});
this._bindingElementToObservers.on('history:redo', (context: any) => {
this.emit('history:redo', context);
});
this._bindingObserversToElement.on('applyingChange', (value: any) => {
this.applyingChange = value;
});
}
link(observers: Observer|Observer[], paths: string|string[]) {
super.link(observers, paths);
this._bindingElementToObservers.link(observers, paths);
this._bindingObserversToElement.link(observers, paths);
}
unlink() {
this._bindingElementToObservers.unlink();
this._bindingObserversToElement.unlink();
super.unlink();
}
clone() {
return new BindingTwoWay({
bindingElementToObservers: this._bindingElementToObservers.clone(),
bindingObserversToElement: this._bindingObserversToElement.clone()
});
}
setValue(value: any) {
this._bindingElementToObservers.setValue(value);
}
setValues(values: any[]) {
this._bindingElementToObservers.setValues(values);
}
addValue(value: any) {
this._bindingElementToObservers.addValue(value);
}
addValues(values: any[]) {
this._bindingElementToObservers.addValues(values);
}
removeValue(value: any) {
this._bindingElementToObservers.removeValue(value);
}
removeValues(values: any[]) {
this._bindingElementToObservers.removeValues(values);
}
set element(value: IBindable | undefined) {
this._element = value;
this._bindingElementToObservers.element = value;
this._bindingObserversToElement.element = value;
}
get element() : IBindable | undefined {
return this._element;
}
set applyingChange(value) {
if (super.applyingChange === value) return;
this._bindingElementToObservers.applyingChange = value;
this._bindingObserversToElement.applyingChange = value;
super.applyingChange = value;
}
get applyingChange() {
return super.applyingChange;
}
set historyCombine(value) {
this._bindingElementToObservers.historyCombine = value;
}
get historyCombine() {
return this._bindingElementToObservers.historyCombine;
}
set historyPrefix(value) {
this._bindingElementToObservers.historyPrefix = value;
}
get historyPrefix() {
return this._bindingElementToObservers.historyPrefix;
}
set historyPostfix(value) {
this._bindingElementToObservers.historyPostfix = value;
}
get historyPostfix() {
return this._bindingElementToObservers.historyPostfix;
}
set historyEnabled(value) {
this._bindingElementToObservers.historyEnabled = value;
}
get historyEnabled() {
return this._bindingElementToObservers.historyEnabled;
}
}
export { BindingTwoWay };
@@ -0,0 +1,14 @@
import { BindingBase, BindingBaseArgs } from './BindingBase';
import { BindingElementToObservers } from './BindingElementToObservers';
import { BindingObserversToElement, BindingObserversToElementArgs } from './BindingObserversToElement';
import { BindingTwoWay, BindingTwoWayArgs } from './BindingTwoWay';
export {
BindingBase,
BindingBaseArgs,
BindingElementToObservers,
BindingObserversToElement,
BindingObserversToElementArgs,
BindingTwoWay,
BindingTwoWayArgs
};
@@ -0,0 +1,17 @@
export const CLASS_COLLAPSED = 'pcui-collapsed';
export const CLASS_COLLAPSIBLE = 'pcui-collapsible';
export const CLASS_DEFAULT_MOUSEDOWN = 'pcui-default-mousedown';
export const CLASS_DISABLED = 'pcui-disabled';
export const CLASS_ERROR = 'pcui-error';
export const CLASS_FLASH = 'flash';
export const CLASS_FLEX = 'pcui-flex';
export const CLASS_FOCUS = 'pcui-focus';
export const CLASS_FONT_REGULAR = 'font-regular';
export const CLASS_FONT_BOLD = 'font-bold';
export const CLASS_GRID = 'pcui-grid';
export const CLASS_HIDDEN = 'pcui-hidden';
export const CLASS_MULTIPLE_VALUES = 'pcui-multiple-values';
export const CLASS_NOT_FLEXIBLE = 'pcui-not-flexible';
export const CLASS_READONLY = 'pcui-readonly';
export const CLASS_RESIZABLE = 'pcui-resizable';
export const CLASS_SCROLLABLE = 'pcui-scrollable';
@@ -0,0 +1,43 @@
import { action } from '@storybook/addon-actions';
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import '../BooleanInput';
import '../NumericInput';
import '../TextInput';
import '../VectorInput';
import { ArrayInput } from './component';
import '../../scss/index.js';
const meta: Meta<typeof ArrayInput> = {
title: 'Components/ArrayInput',
component: ArrayInput
};
export default meta;
type Story = StoryObj<typeof ArrayInput>;
export const String: Story = {
render: args => <ArrayInput type='string' onChange={action('value-change')} value={[['foobar']]} {...args} />
};
export const Number: Story = {
render: args => <ArrayInput type="number" onChange={action('value-change')} value={[[0]]} {...args} />
};
export const Boolean: Story = {
render: args => <ArrayInput type='boolean' onChange={action('value-change')} value={[[true]]} {...args} />
};
export const Vec2: Story = {
render: args => <ArrayInput type='vec2' elementArgs={{ dimensions: 2 }} onChange={action('value-change')} value={[[1, 2]]} {...args} />
};
export const Vec3: Story = {
render: args => <ArrayInput type='vec3' elementArgs={{ dimensions: 3 }} onChange={action('value-change')} value={[[1, 2, 3]]} {...args} />
};
export const Vec4: Story = {
render: args => <ArrayInput type='vec4' elementArgs={{ dimensions: 4 }} onChange={action('value-change')} value={[[1, 2, 3, 4]]} {...args} />
};
@@ -0,0 +1,21 @@
import { Element } from '../Element/component';
import { ArrayInput as ArrayInputClass, ArrayInputArgs } from './index';
/**
* Element that allows editing an array of values.
*/
class ArrayInput extends Element<ArrayInputArgs, any> {
constructor(props: ArrayInputArgs) {
super(props);
this.elementClass = ArrayInputClass;
}
render() {
return super.render();
}
}
ArrayInput.ctor = ArrayInputClass;
export { ArrayInput };
@@ -0,0 +1,606 @@
import { Observer } from '@playcanvas/observer';
import * as utils from '../../helpers/utils';
import { Button } from '../Button';
import { Container } from '../Container';
import { Element, ElementArgs, IBindable, IBindableArgs, IFocusable } from '../Element';
import { NumericInput } from '../NumericInput';
import { Panel } from '../Panel';
const CLASS_ARRAY_INPUT = 'pcui-array-input';
const CLASS_ARRAY_EMPTY = 'pcui-array-empty';
const CLASS_ARRAY_SIZE = `${CLASS_ARRAY_INPUT}-size`;
const CLASS_ARRAY_CONTAINER = `${CLASS_ARRAY_INPUT}-items`;
const CLASS_ARRAY_ELEMENT = `${CLASS_ARRAY_INPUT}-item`;
const CLASS_ARRAY_DELETE = `${CLASS_ARRAY_ELEMENT}-delete`;
/**
* The arguments for the {@link ArrayInput} constructor.
*/
interface ArrayInputArgs extends ElementArgs, IBindableArgs {
/**
* The type of values that the array can hold. Can be one of the following:
*
* - `boolean`
* - `number`
* - `string`
* - `vec2`
* - `vec3`
* - `vec4`
*
* Defaults to `string`.
*/
type?: 'boolean' | 'number' | 'string' | 'vec2' | 'vec3' | 'vec4';
/**
* Arguments for each array Element.
*/
elementArgs?: Array<any>;
/**
* If `true` then editing the number of elements that the array has will not be allowed.
*/
fixedSize?: boolean;
/**
* If `true` then the array will be rendered using panels. Defaults to `false`.
*/
usePanels?: boolean;
/**
* Used to specify the default values for each element in the array. Defaults to `null`.
*/
getDefaultFn?: () => any;
}
/**
* Element that allows editing an array of values.
*/
class ArrayInput extends Element implements IFocusable, IBindable {
/**
* Fired when an array element is linked to observers.
*
* @event
* @example
* ```ts
* arrayInput.on('linkElement', (element: Element, index: number, path: string) => {
* console.log(`Element ${index} is now linked to ${path}`);
* });
* ```
*/
public static readonly EVENT_LINK_ELEMENT: 'linkElement';
/**
* Fired when an array element is unlinked from observers.
*
* @event
* @example
* ```ts
* arrayInput.on('unlinkElement', (element: Element, index: number) => {
* console.log(`Element ${index} is now unlinked`);
* });
* ```
*/
public static readonly EVENT_UNLINK_ELEMENT: 'unlinkElement';
static DEFAULTS = {
boolean: false,
number: 0,
string: '',
vec2: [0, 0],
vec3: [0, 0, 0],
vec4: [0, 0, 0, 0]
};
protected _container: Container;
protected _usePanels: boolean;
protected _fixedSize: boolean;
protected _inputSize: NumericInput;
protected _suspendSizeChangeEvt = false;
protected _containerArray: Container;
protected _arrayElements: any;
protected _suspendArrayElementEvts = false;
protected _arrayElementChangeTimeout: number = null;
protected _getDefaultFn: any;
protected _valueType: string;
protected _elementType: string;
protected _elementArgs: any;
protected _values: any[];
protected _renderChanges: boolean;
/**
* Creates a new ArrayInput.
*
* @param args - The arguments.
*/
constructor(args: Readonly<ArrayInputArgs> = {}) {
const container = new Container({
dom: args.dom,
flex: true
});
const elementArgs = { ...args, dom: container.dom };
// remove binding because we want to set it later
delete elementArgs.binding;
super(elementArgs);
this._container = container;
this._container.parent = this;
this.class.add(CLASS_ARRAY_INPUT, CLASS_ARRAY_EMPTY);
this._usePanels = args.usePanels ?? false;
this._fixedSize = !!args.fixedSize;
this._inputSize = new NumericInput({
class: [CLASS_ARRAY_SIZE],
placeholder: 'Array Size',
value: 0,
hideSlider: true,
step: 1,
precision: 0,
min: 0,
readOnly: this._fixedSize
});
this._inputSize.on('change', (value: number) => {
this._onSizeChange(value);
});
this._inputSize.on('focus', () => {
this.emit('focus');
});
this._inputSize.on('blur', () => {
this.emit('blur');
});
this._container.append(this._inputSize);
this._containerArray = new Container({
class: CLASS_ARRAY_CONTAINER,
hidden: true
});
this._containerArray.on('append', () => {
this._containerArray.hidden = false;
});
this._containerArray.on('remove', () => {
this._containerArray.hidden = this._arrayElements.length === 0;
});
this._container.append(this._containerArray);
this._getDefaultFn = args.getDefaultFn ?? null;
// @ts-ignore
let valueType = args.elementArgs && args.elementArgs.type || args.type;
if (!ArrayInput.DEFAULTS.hasOwnProperty(valueType)) {
valueType = 'string';
}
this._valueType = valueType;
this._elementType = args.type ?? 'string';
if (args.elementArgs) {
this._elementArgs = args.elementArgs;
} else {
delete elementArgs.dom;
this._elementArgs = elementArgs;
}
this._arrayElements = [];
// set binding now
this.binding = args.binding;
this._values = [];
if (args.value) {
this.value = args.value;
}
this.renderChanges = args.renderChanges ?? false;
}
destroy() {
if (this._destroyed) return;
this._arrayElements.length = 0;
super.destroy();
}
protected _onSizeChange(size: number) {
// if size is explicitly 0 then add empty class
// size can also be null with multi-select so do not
// check just !size
if (size === 0) {
this.class.add(CLASS_ARRAY_EMPTY);
} else {
this.class.remove(CLASS_ARRAY_EMPTY);
}
if (size === null) return;
if (this._suspendSizeChangeEvt) return;
// initialize default value for each new array element
let defaultValue: any;
const initDefaultValue = () => {
if (this._getDefaultFn) {
defaultValue = this._getDefaultFn();
} else {
defaultValue = ArrayInput.DEFAULTS[this._valueType as keyof typeof ArrayInput.DEFAULTS];
if (this._valueType === 'curveset') {
defaultValue = utils.deepCopy(defaultValue);
if (Array.isArray(this._elementArgs.curves)) {
for (let i = 0; i < this._elementArgs.curves.length; i++) {
defaultValue.keys.push([0, 0]);
}
}
} else if (this._valueType === 'gradient') {
defaultValue = utils.deepCopy(defaultValue);
if (this._elementArgs.channels) {
for (let i = 0; i < this._elementArgs.channels; i++) {
defaultValue.keys.push([0, 1]);
}
}
}
}
};
// resize array
const values = this._values.map((array) => {
if (!array) {
array = new Array(size);
for (let i = 0; i < size; i++) {
if (defaultValue === undefined) initDefaultValue();
array[i] = utils.deepCopy(defaultValue);
}
} else if (array.length < size) {
const newArray = new Array(size - array.length);
for (let i = 0; i < newArray.length; i++) {
if (defaultValue === undefined) initDefaultValue();
newArray[i] = utils.deepCopy(defaultValue);
}
array = array.concat(newArray);
} else {
const newArray = new Array(size);
for (let i = 0; i < size; i++) {
newArray[i] = utils.deepCopy(array[i]);
}
array = newArray;
}
return array;
});
if (!values.length) {
const array = new Array(size);
for (let i = 0; i < size; i++) {
if (defaultValue === undefined) initDefaultValue();
array[i] = utils.deepCopy(defaultValue);
}
values.push(array);
}
this._updateValues(values, true);
}
protected _createArrayElement() {
const args = Object.assign({}, this._elementArgs);
if (args.binding) {
args.binding = args.binding.clone();
} else if (this._binding) {
args.binding = this._binding.clone();
}
// set renderChanges after value is set
// to prevent flashing on initial value set
args.renderChanges = false;
let container;
if (this._usePanels) {
container = new Panel({
headerText: `[${this._arrayElements.length}]`,
removable: !this._fixedSize,
collapsible: true,
class: [CLASS_ARRAY_ELEMENT, `${CLASS_ARRAY_ELEMENT}-${this._elementType}`]
});
} else {
container = new Container({
flex: true,
flexDirection: 'row',
alignItems: 'center',
class: [CLASS_ARRAY_ELEMENT, `${CLASS_ARRAY_ELEMENT}-${this._elementType}`]
});
}
if (this._elementType === 'json' && args.attributes) {
args.attributes = args.attributes.map((attr: any) => {
if (!attr.path) return attr;
// fix paths to include array element index
attr = Object.assign({}, attr);
const parts = attr.path.split('.');
parts.splice(parts.length - 1, 0, this._arrayElements.length);
attr.path = parts.join('.');
return attr;
});
}
const element = Element.create(this._elementType, args);
container.append(element);
element.renderChanges = this.renderChanges;
const entry = {
container: container,
element: element
};
this._arrayElements.push(entry);
if (!this._usePanels) {
if (!this._fixedSize) {
const btnDelete = new Button({
icon: 'E289',
size: 'small',
class: CLASS_ARRAY_DELETE,
tabIndex: -1 // skip buttons on tab
});
btnDelete.on('click', () => {
this._removeArrayElement(entry);
});
container.append(btnDelete);
}
} else {
container.on('click:remove', () => {
this._removeArrayElement(entry);
});
}
element.on('change', (value: any) => {
this._onArrayElementChange(entry, value);
});
this._containerArray.append(container);
return entry;
}
protected _removeArrayElement(entry: any) {
const index = this._arrayElements.indexOf(entry);
if (index === -1) return;
// remove row from every array in values
const values = this._values.map((array) => {
if (!array) return null;
array.splice(index, 1);
return array;
});
this._updateValues(values, true);
}
protected _onArrayElementChange(entry: any, value: any) {
if (this._suspendArrayElementEvts) return;
const index = this._arrayElements.indexOf(entry);
if (index === -1) return;
// Set the value to the same row of every array in values.
this._values.forEach((array) => {
if (array && array.length > index) {
if (this._valueType === 'curveset') {
// curveset is passing the value in an array
array[index] = Array.isArray(value) ? value[0] : value;
} else {
array[index] = value;
}
}
});
// use a timeout here because when our values change they will
// first emit change events on each array element. However since the
// whole array changed we are going to fire a 'change' event later from
// our '_updateValues' function. We only want to emit a 'change' event
// here when only the array element changed value and not the whole array so
// wait a bit and fire the change event later otherwise the _updateValues function
// will cancel this timeout and fire a change event for the whole array instead
this._arrayElementChangeTimeout = window.setTimeout(() => {
this._arrayElementChangeTimeout = null;
this.emit('change', this.value);
});
}
protected _linkArrayElement(element: any, index: number) {
const observers = this._binding.observers;
const paths = this._binding.paths;
const useSinglePath = paths.length === 1 || observers.length !== paths.length;
element.unlink();
element.value = null;
this.emit('unlinkElement', element, index);
const path = (useSinglePath ? `${paths[0]}.${index}` : paths.map((path: string) => `${path}.${index}`));
element.link(observers, path);
this.emit('linkElement', element, index, path);
}
protected _updateValues(values: any, applyToBinding: boolean) {
this._values = values || [];
this._suspendArrayElementEvts = true;
this._suspendSizeChangeEvt = true;
// apply values to the binding
if (applyToBinding && this._binding) {
this._binding.setValues(values);
}
// each row of this array holds
// all the values for that row
const valuesPerRow: any[] = [];
// holds the length of each array
const arrayLengths: any[] = [];
values.forEach((array: any) => {
if (!array) return;
arrayLengths.push(array.length);
array.forEach((item: any, i: number) => {
if (!valuesPerRow[i]) {
valuesPerRow[i] = [];
}
valuesPerRow[i].push(item);
});
});
let lastElementIndex = -1;
for (let i = 0; i < valuesPerRow.length; i++) {
// if the number of values on this row does not match
// the number of arrays then stop adding rows
if (valuesPerRow[i].length !== values.length) {
break;
}
// create row if it doesn't exist
if (!this._arrayElements[i]) {
this._createArrayElement();
}
// bind to observers for that row or just display the values
if (this._binding && this._binding.observers) {
this._linkArrayElement(this._arrayElements[i].element, i);
} else {
if (valuesPerRow[i].length > 1) {
this._arrayElements[i].element.values = valuesPerRow[i];
} else {
this._arrayElements[i].element.value = valuesPerRow[i][0];
}
}
lastElementIndex = i;
}
// destroy elements that are no longer in our values
for (let i = this._arrayElements.length - 1; i > lastElementIndex; i--) {
this._arrayElements[i].container.destroy();
this._arrayElements.splice(i, 1);
}
this._inputSize.values = arrayLengths;
this._suspendSizeChangeEvt = false;
this._suspendArrayElementEvts = false;
if (this._arrayElementChangeTimeout) {
window.clearTimeout(this._arrayElementChangeTimeout);
this._arrayElementChangeTimeout = null;
}
this.emit('change', this.value);
}
focus() {
this._inputSize.focus();
}
blur() {
this._inputSize.blur();
}
unlink() {
super.unlink();
this._arrayElements.forEach((entry: { element: Element; }) => {
entry.element.unlink();
});
}
link(observers: Observer|Observer[], paths: string|string[]) {
super.link(observers, paths);
this._arrayElements.forEach((entry: { element: Element; }, index: number) => {
this._linkArrayElement(entry.element, index);
});
}
/**
* Executes the specified function for each array element.
*
* @param fn - The function with signature (element, index) => bool to execute. If the function
* returns `false` then the iteration will early out.
*/
forEachArrayElement(fn: (element: Element, index: number) => false | void) {
this._containerArray.forEachChild((element, i) => {
return fn(element.dom.firstChild.ui, i);
});
}
// override binding setter to create
// the same type of binding on each array element too
set binding(value) {
super.binding = value;
this._arrayElements.forEach((entry: { element: Element; }) => {
entry.element.binding = value ? value.clone() : null;
});
}
get binding() {
return super.binding;
}
set value(value) {
if (!Array.isArray(value)) {
value = [];
}
const current = this.value || [];
if (utils.arrayEquals(current, value)) return;
// update values and binding
this._updateValues(new Array(this._values.length || 1).fill(value), true);
}
get value() {
// construct value from values of array elements
return this._arrayElements.map((entry: { element: { value: any; }; }) => entry.element.value);
}
/* eslint accessor-pairs: 0 */
set values(values: any) {
if (utils.arrayEquals(this._values, values)) return;
// update values but do not update binding
this._updateValues(values, false);
}
set renderChanges(value) {
this._renderChanges = value;
this._arrayElements.forEach((entry: { element: { renderChanges: any; }; }) => {
entry.element.renderChanges = value;
});
}
get renderChanges() {
return this._renderChanges;
}
}
for (const type in ArrayInput.DEFAULTS) {
Element.register(`array:${type}`, ArrayInput, { type: type, renderChanges: true });
}
Element.register('array:select', ArrayInput, { type: 'select', renderChanges: true });
export { ArrayInput, ArrayInputArgs };
@@ -0,0 +1,50 @@
.pcui-array-input {
margin: $element-margin;
min-width: 0; // this prevents the element from overflowing
> .pcui-numeric-input {
margin: 0 0 $element-margin;
}
}
.pcui-array-input.pcui-array-empty {
> .pcui-numeric-input {
margin: 0;
}
}
.pcui-array-input-item {
> * {
margin-top: 1px;
margin-bottom: 1px;
}
// field should be stretched
// except for ones marked
// as specifically not flexible
// and panel headers
> *:first-child {
&:not(.pcui-not-flexible, .pcui-panel-header) {
flex: 1;
}
}
> .pcui-button {
margin-left: - calc($element-margin / 2);
margin-right: 0;
background-color: transparent;
border: 0;
}
}
.pcui-array-input-item-asset {
> .pcui-button {
margin-top: 36px;
}
}
.pcui-array-input.pcui-readonly {
.pcui-array-input-item-delete {
display: none;
}
}
@@ -0,0 +1,19 @@
import { action } from '@storybook/addon-actions';
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import { BooleanInput } from './component';
import '../../scss/index.js';
const meta: Meta<typeof BooleanInput> = {
title: 'Components/BooleanInput',
component: BooleanInput
};
export default meta;
type Story = StoryObj<typeof BooleanInput>;
export const Main: Story = {
render: args => <BooleanInput onChange={action('toggle')} {...args} />
};
@@ -0,0 +1,21 @@
import { Element } from '../Element/component';
import { BooleanInput as BooleanInputClass, BooleanInputArgs } from './index';
/**
* A checkbox element.
*/
class BooleanInput extends Element<BooleanInputArgs, any> {
constructor(props: BooleanInputArgs = {}) {
super(props);
this.elementClass = BooleanInputClass;
}
render() {
return super.render();
}
}
BooleanInput.ctor = BooleanInputClass;
export { BooleanInput };
@@ -0,0 +1,166 @@
import { CLASS_NOT_FLEXIBLE, CLASS_MULTIPLE_VALUES } from '../../class';
import { Element, ElementArgs, IBindable, IBindableArgs, IFocusable } from '../Element';
const CLASS_BOOLEAN_INPUT = 'pcui-boolean-input';
const CLASS_BOOLEAN_INPUT_TICKED = `${CLASS_BOOLEAN_INPUT}-ticked`;
const CLASS_BOOLEAN_INPUT_TOGGLE = `${CLASS_BOOLEAN_INPUT}-toggle`;
/**
* The arguments for the {@link BooleanInput} constructor.
*/
interface BooleanInputArgs extends ElementArgs, IBindableArgs {
/**
* Sets the tabIndex of the {@link BooleanInput}. Defaults to 0.
*/
tabIndex?: number,
/**
* The type of checkbox. Currently can be `null` or 'toggle'.
*/
type?: string
}
/**
* A checkbox element.
*/
class BooleanInput extends Element implements IBindable, IFocusable {
protected _value: boolean;
protected _renderChanges: boolean;
/**
* Creates a new BooleanInput.
*
* @param args - The arguments.
*/
constructor(args: Readonly<BooleanInputArgs> = {}) {
super({ tabIndex: 0, ...args });
if (args.type === 'toggle') {
this.class.add(CLASS_BOOLEAN_INPUT_TOGGLE);
} else {
this.class.add(CLASS_BOOLEAN_INPUT);
}
this.class.add(CLASS_NOT_FLEXIBLE);
this.dom.addEventListener('keydown', this._onKeyDown);
this.dom.addEventListener('focus', this._onFocus);
this.dom.addEventListener('blur', this._onBlur);
this._value = null;
if (args.value !== undefined) {
this.value = args.value;
}
this.renderChanges = args.renderChanges ?? false;
}
destroy() {
if (this._destroyed) return;
this.dom.removeEventListener('keydown', this._onKeyDown);
this.dom.removeEventListener('focus', this._onFocus);
this.dom.removeEventListener('blur', this._onBlur);
super.destroy();
}
protected _onClick(evt: MouseEvent) {
if (this.enabled) {
this.focus();
}
if (this.enabled && !this.readOnly) {
this.value = !this.value;
}
return super._onClick(evt);
}
protected _onKeyDown = (evt: KeyboardEvent) => {
if (evt.key === 'Escape') {
this.blur();
return;
}
if (!this.enabled || this.readOnly) return;
if (evt.key === ' ') {
evt.stopPropagation();
evt.preventDefault();
this.value = !this.value;
}
};
protected _onFocus = () => {
this.emit('focus');
};
protected _onBlur = () => {
this.emit('blur');
};
protected _updateValue(value: boolean) {
this.class.remove(CLASS_MULTIPLE_VALUES);
if (value === this.value) return false;
this._value = value;
if (value) {
this.class.add(CLASS_BOOLEAN_INPUT_TICKED);
} else {
this.class.remove(CLASS_BOOLEAN_INPUT_TICKED);
}
if (this.renderChanges) {
this.flash();
}
this.emit('change', value);
return true;
}
focus() {
this.dom.focus();
}
blur() {
this.dom.blur();
}
set value(value: boolean) {
const changed = this._updateValue(value);
if (changed && this._binding) {
this._binding.setValue(value);
}
}
get value(): boolean {
return this._value;
}
/* eslint accessor-pairs: 0 */
set values(values: boolean[]) {
const different = values.some(v => v !== values[0]);
if (different) {
this._updateValue(null);
this.class.add(CLASS_MULTIPLE_VALUES);
} else {
this._updateValue(values[0]);
}
}
set renderChanges(value: boolean) {
this._renderChanges = value;
}
get renderChanges(): boolean {
return this._renderChanges;
}
}
Element.register('boolean', BooleanInput, { renderChanges: true });
export { BooleanInput, BooleanInputArgs };
@@ -0,0 +1,159 @@
// boolean input
.pcui-boolean-input {
display: inline-block;
position: relative;
box-sizing: border-box;
background-color: $bcg-dark;
color: #fff;
width: 14px;
height: 14px;
line-height: 1;
overflow: hidden;
margin: $element-margin;
transition: opacity 100ms, background-color 100ms, box-shadow 100ms;
&:focus {
outline: none;
}
}
// ticked
.pcui-boolean-input.pcui-boolean-input-ticked {
background-color: $text-secondary;
&::after {
@extend .font-icon;
content: '\E372';
color: $bcg-darkest;
background-color: inherit;
font-size: 19px;
display: block;
margin-top: -2px;
margin-left: -2px;
}
}
// hover / focus
.pcui-boolean-input:not(.pcui-disabled, .pcui-readonly) {
&:hover,
&:focus {
cursor: pointer;
background-color: $bcg-darker;
box-shadow: $element-shadow-hover;
}
&.pcui-boolean-input-ticked {
&:hover,
&:focus {
background-color: $text-secondary;
}
}
}
// disabled state
.pcui-boolean-input.pcui-disabled {
opacity: $element-opacity-disabled;
}
// showing multiple values
.pcui-boolean-input.pcui-multiple-values {
&::after {
position: absolute;
font-size: 17px;
font-weight: bold;
color: $text-secondary;
left: 4px;
top: -3px;
content: '-';
}
}
// toggle type
.pcui-boolean-input-toggle {
display: inline-block;
position: relative;
width: 30px;
height: 16px;
border-radius: 8px;
flex-shrink: 0;
border: 1px solid $bcg-darker;
box-sizing: border-box;
background-color: $bcg-primary;
color: #fff;
line-height: 1;
overflow: hidden;
margin: $element-margin;
transition: opacity 100ms, background-color 100ms, box-shadow 100ms;
&:focus {
outline: none;
}
&::after {
content: '\00a0';
position: absolute;
top: 1px;
left: 1px;
width: 12px;
height: 12px;
border-radius: 6px;
background-color: $text-darkest;
transition: left 100ms ease, background-color 100ms ease;
}
}
// ticked
.pcui-boolean-input-toggle.pcui-boolean-input-ticked {
border-color: $bcg-darker;
&::after {
left: 15px;
background-color: color.mix($text-darkest, #7f7, 50%);
}
}
// hover focus
.pcui-boolean-input-toggle:not(.pcui-disabled, .pcui-readonly) {
&:hover,
&:focus {
cursor: pointer;
border-color: $bcg-darkest;
background-color: $bcg-darkest;
box-shadow: $element-shadow-hover;
&::after {
background-color: $error-secondary;
}
}
&.pcui-boolean-input-ticked {
&:hover,
&:focus {
border-color: $bcg-darkest;
background-color: $bcg-darkest;
}
&::after {
background-color: #7f7;
}
}
}
// readonly
.pcui-boolean-input-toggle.pcui-readonly {
opacity: $element-opacity-readonly;
}
// disabled
.pcui-boolean-input-toggle.pcui-disabled {
opacity: $element-opacity-disabled;
}
// multiple values
.pcui-boolean-input-toggle.pcui-multiple-values {
&::after {
left: 8px;
background-color: rgba($text-dark, 0.25);
}
}
@@ -0,0 +1,23 @@
import { action } from '@storybook/addon-actions';
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import { Button } from './component';
import '../../scss/index.js';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Text: Story = {
render: args => <Button text='Hello World' onClick={action('button-click')} {...args} />
};
export const TextAndIcon: Story = {
render: args => <Button text='Hello World' icon='E401' onClick={action('button-click')} {...args} />
};
@@ -0,0 +1,24 @@
import * as React from 'react';
import { Element } from '../Element/component';
import { Button as ButtonClass, ButtonArgs } from './index';
/**
* User input with click interaction
*/
class Button extends Element<ButtonArgs, any> {
constructor(props: ButtonArgs = {}) {
super(props);
this.elementClass = ButtonClass;
}
render() {
// @ts-ignore
return <button ref={this.attachElement} />;
}
}
Button.ctor = ButtonClass;
export { Button };
@@ -0,0 +1,159 @@
import { Element, ElementArgs } from '../Element';
const CLASS_BUTTON = 'pcui-button';
/**
* The arguments for the {@link Button} constructor.
*/
interface ButtonArgs extends ElementArgs {
/**
* If `true`, the {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML innerHTML} property will be
* used to set the text. Otherwise, {@link https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent textContent}
* will be used instead. Defaults to `false`.
*/
unsafe?: boolean;
/**
* Sets the text of the button. Defaults to ''.
*/
text?: string,
/**
* The CSS code for an icon for the button. e.g. 'E401' (notice we omit the '\\' character). Defaults to ''.
*/
icon?: string,
/**
* Sets the 'size' type of the button. Can be 'small' or `null`. Defaults to `null`.
*/
size?: 'small'
}
/**
* User input with click interaction.
*/
class Button extends Element {
protected _unsafe: boolean;
protected _text: string;
protected _icon: string;
protected _size: string | null;
/**
* Creates a new Button.
*
* @param args - The arguments.
*/
constructor(args: Readonly<ButtonArgs> = {}) {
super({ dom: 'button', ...args });
this.class.add(CLASS_BUTTON);
this._unsafe = args.unsafe;
this.text = args.text;
this.size = args.size;
this.icon = args.icon;
this.dom.addEventListener('keydown', this._onKeyDown);
}
destroy() {
if (this._destroyed) return;
this.dom.removeEventListener('keydown', this._onKeyDown);
super.destroy();
}
protected _onKeyDown = (evt: KeyboardEvent) => {
if (evt.key === 'Escape') {
this.blur();
} else if (evt.key === 'Enter') {
this._onClick(evt);
}
};
protected _onClick(evt: Event) {
this.blur();
if (this.readOnly) return;
super._onClick(evt);
}
focus() {
this.dom.focus();
}
blur() {
this.dom.blur();
}
/**
* Sets the text of the button.
*/
set text(value: string) {
if (this._text === value) return;
this._text = value;
if (this._unsafe) {
this.dom.innerHTML = value;
} else {
this.dom.textContent = value;
}
}
/**
* Gets the text of the button.
*/
get text(): string {
return this._text;
}
/**
* Sets the CSS code for an icon for the button. e.g. 'E401' (notice we omit the '\\' character).
*/
set icon(value: string) {
if (this._icon === value || !value.match(/^E\d{0,4}$/)) return;
this._icon = value;
if (value) {
// set data-icon attribute but first convert the value to a code point
this.dom.setAttribute('data-icon', String.fromCodePoint(parseInt(value, 16)));
} else {
this.dom.removeAttribute('data-icon');
}
}
/**
* Gets the CSS code for an icon for the button.
*/
get icon(): string {
return this._icon;
}
/**
* Sets the 'size' type of the button. Can be null or 'small'.
*/
set size(value: string) {
if (this._size === value) return;
if (this._size) {
this.class.remove(`pcui-${this._size}`);
this._size = null;
}
this._size = value;
if (this._size) {
this.class.add(`pcui-${this._size}`);
}
}
/**
* Gets the 'size' type of the button.
*/
get size(): string {
return this._size;
}
}
Element.register('button', Button);
export { Button, ButtonArgs };
@@ -0,0 +1,83 @@
// buttons
.pcui-button {
@extend .pcui-no-select;
display: inline-block;
border: 1px solid $bcg-darkest;
border-radius: 2px;
box-sizing: border-box;
background-color: $bcg-dark;
color: $text-secondary;
padding: 0 8px;
margin: $element-margin;
height: 28px;
line-height: 28px;
max-height: 100%;
vertical-align: middle;
font-size: 12px;
font-weight: 600;
text-align: center;
white-space: nowrap;
cursor: pointer;
transition: color 100ms, opacity 100ms, box-shadow 100ms;
overflow: hidden;
text-overflow: ellipsis;
}
// icon
.pcui-button[data-icon] {
// show icon using data-icon attribute
// as the content
&::before {
content: attr(data-icon);
@extend .font-icon;
font-weight: 100;
font-size: inherit;
margin-right: $element-margin;
vertical-align: middle;
}
// remove right margin from icons
// if the button has no text
&:empty {
&::before {
margin-right: 0;
}
}
}
// focus / hover states
.pcui-button:not(.pcui-disabled, .pcui-readonly) {
&:hover,
&:focus {
color: $text-primary;
background-color: $bcg-dark;
box-shadow: $element-shadow-hover;
}
&:active {
background-color: $bcg-darkest;
box-shadow: none;
}
}
// readonly states
.pcui-button.pcui-readonly {
opacity: $element-opacity-readonly;
cursor: default;
}
// disabled states
.pcui-button.pcui-disabled {
opacity: $element-opacity-disabled;
cursor: default;
}
// small button
.pcui-button.pcui-small {
height: 24px;
line-height: 24px;
font-size: 10px;
}
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import { Canvas } from './component';
import '../../scss/index.js';
const meta: Meta<typeof Canvas> = {
title: 'Components/Canvas',
component: Canvas
};
export default meta;
type Story = StoryObj<typeof Canvas>;
export const Main: Story = {
render: args => <Canvas {...args} />
};
@@ -0,0 +1,24 @@
import * as React from 'react';
import { Element } from '../Element/component';
import { Canvas as CanvasClass, CanvasArgs } from './index';
/**
* Represents a Canvas
*/
class Canvas extends Element<CanvasArgs, any> {
constructor(props: CanvasArgs = {}) {
super(props);
this.elementClass = CanvasClass;
}
render() {
// @ts-ignore
return <canvas ref={this.attachElement}/>;
}
}
Canvas.ctor = CanvasClass;
export { Canvas };
@@ -0,0 +1,141 @@
import { Element, ElementArgs } from '../Element';
const CLASS_ROOT = 'pcui-canvas';
/**
* The arguments for the {@link Canvas} constructor.
*/
interface CanvasArgs extends ElementArgs {
/**
* Whether the canvas should use the {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio devicePixelRatio}.
* Defaults to `false`.
*/
useDevicePixelRatio?: boolean;
}
/**
* Represents a Canvas.
*/
class Canvas extends Element {
protected _width = 300;
protected _height = 150;
protected _ratio = 1;
/**
* Creates a new Canvas.
*
* @param args - The arguments.
*/
constructor(args: Readonly<CanvasArgs> = {}) {
super({ dom: 'canvas', ...args });
this.class.add(CLASS_ROOT);
const { useDevicePixelRatio = false } = args;
this._ratio = useDevicePixelRatio ? window.devicePixelRatio : 1;
// Disable I-bar cursor on click+drag
this.dom.onselectstart = (evt: Event) => {
return false;
};
}
/**
* Resize the canvas using the given width and height parameters.
*
* @param width - The new width of the canvas.
* @param height - The new height of the canvas.
*/
resize(width: number, height: number) {
if (this._width === width && this._height === height) {
return;
}
this._width = width;
this._height = height;
const canvas = this._dom as HTMLCanvasElement;
canvas.width = this.pixelWidth;
canvas.height = this.pixelHeight;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
this.emit('resize', width, height);
}
/**
* Sets the width of the canvas.
*/
set width(value: number) {
if (this._width === value) {
return;
}
this._width = value;
const canvas = this._dom as HTMLCanvasElement;
canvas.width = this.pixelWidth;
canvas.style.width = `${value}px`;
this.emit('resize', this._width, this._height);
}
/**
* Gets the width of the canvas.
*/
get width(): number {
return this._width;
}
/**
* Sets the height of the canvas.
*/
set height(value: number) {
if (this._height === value) {
return;
}
this._height = value;
const canvas = this._dom as HTMLCanvasElement;
canvas.height = this.pixelHeight;
canvas.style.height = `${value}px`;
this.emit('resize', this._width, this._height);
}
/**
* Gets the height of the canvas.
*/
get height(): number {
return this._height;
}
/**
* Gets the pixel height of the canvas.
*/
get pixelWidth(): number {
return Math.floor(this._width * this._ratio);
}
/**
* Gets the pixel height of the canvas.
*/
get pixelHeight(): number {
return Math.floor(this._height * this._ratio);
}
/**
* Gets the pixel ratio of the canvas.
*/
get pixelRatio(): number {
return this._ratio;
}
}
Element.register('canvas', Canvas);
export { Canvas, CanvasArgs };
@@ -0,0 +1,3 @@
.pcui-canvas {
@extend .pcui-no-select;
}
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import { Code } from './component';
import '../../scss/index.js';
const meta: Meta<typeof Code> = {
title: 'Components/Code',
component: Code
};
export default meta;
type Story = StoryObj<typeof Code>;
export const Main: Story = {
render: args => <Code {...args} />
};
@@ -0,0 +1,23 @@
import { Element } from '../Element/component';
import { Code as CodeClass, CodeArgs } from './index';
/**
* Represents a code block.
*/
class Code extends Element<CodeArgs, any> {
static defaultProps: CodeArgs;
constructor(props: CodeArgs) {
super(props);
this.elementClass = CodeClass;
}
render() {
return super.render();
}
}
Code.ctor = CodeClass;
export { Code };
@@ -0,0 +1,64 @@
import { Container, ContainerArgs } from '../Container';
import { Element } from '../Element';
import { Label } from '../Label';
const CLASS_ROOT = 'pcui-code';
const CLASS_INNER = `${CLASS_ROOT}-inner`;
/**
* The arguments for the {@link Code} constructor.
*/
interface CodeArgs extends ContainerArgs {
/**
* Sets the text to display in the code block.
*/
text?: string
}
/**
* Represents a code block.
*/
class Code extends Container {
protected _inner: Label;
protected _text: string;
/**
* Creates a new Code.
*
* @param args - The arguments.
*/
constructor(args: Readonly<CodeArgs> = {}) {
super(args);
this.class.add(CLASS_ROOT);
this._inner = new Label({
class: CLASS_INNER
});
this.append(this._inner);
if (args.text) {
this.text = args.text;
}
}
/**
* Sets the text to display in the code block.
*/
set text(value) {
this._text = value;
this._inner.text = value;
}
/**
* Gets the text to display in the code block.
*/
get text() {
return this._text;
}
}
Element.register('code', Code);
export { Code, CodeArgs };
@@ -0,0 +1,12 @@
.pcui-code {
background: #20292b;
overflow: auto;
.pcui-code-inner {
color: #f60;
font-family: inconsolatamedium, Monaco, Menlo, 'Ubuntu Mono', Consolas, source-code-pro, monospace;
font-weight: normal;
font-size: 10px;
white-space: pre;
}
}
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import { ColorPicker } from './component';
import '../../scss/index.js';
const meta: Meta<typeof ColorPicker> = {
title: 'Components/ColorPicker',
component: ColorPicker
};
export default meta;
type Story = StoryObj<typeof ColorPicker>;
export const RGB: Story = {
render: args => <ColorPicker value={[255, 0, 0]} {...args} />
};
export const RGBA: Story = {
render: args => <ColorPicker channels={4} value={[0, 255, 0, 1]} {...args} />
};
@@ -0,0 +1,24 @@
import * as React from 'react';
import { Element } from '../Element/component';
import { ColorPicker as ColorPickerClass, ColorPickerArgs } from './index';
/**
* Represents a color picker
*/
class ColorPicker extends Element<ColorPickerArgs, any> {
constructor(props: ColorPickerArgs) {
super(props);
this.elementClass = ColorPickerClass;
}
render() {
// @ts-ignore
return <div ref={this.attachElement}/>;
}
}
ColorPicker.ctor = ColorPickerClass;
export { ColorPicker };
@@ -0,0 +1,787 @@
import { EventHandle } from '@playcanvas/observer';
import { CLASS_MULTIPLE_VALUES, CLASS_NOT_FLEXIBLE } from '../../class';
import { _hsv2rgb, _rgb2hsv } from '../../Math/color-value';
import { Element, ElementArgs, IBindable, IBindableArgs } from '../Element';
import { NumericInput } from '../NumericInput';
import { Overlay } from '../Overlay';
import { TextInput } from '../TextInput';
const CLASS_ROOT = 'pcui-color-input';
/**
* The arguments for the {@link ColorPicker} constructor.
*/
interface ColorPickerArgs extends ElementArgs, IBindableArgs {
/**
* An array of 4 integers containing the RGBA values the picker should be initialized to. Defaults to `[0, 0, 255, 1]`.
*/
value?: number[];
/**
* Number of color channels. Defaults to 3. Changing to 4 adds the option to change the alpha value.
*/
channels?: number;
}
/**
* Represents a color picker.
*/
class ColorPicker extends Element implements IBindable {
protected _historyCombine = false;
protected _historyPostfix: string = null;
protected _value: number[];
protected _channels: number;
protected _size = 144;
protected _directInput = true;
protected _colorHSV = [0, 0, 0];
protected _pickerChannels: NumericInput[] = [];
protected _channelsNumber = 4;
protected _changing = false;
protected _dragging = false;
protected _callingCallback = false;
protected _overlay: Overlay;
protected _domColor: HTMLDivElement;
protected _pickRect: HTMLDivElement;
protected _pickRectPointerId: number = null;
protected _pickRectWhite: HTMLDivElement;
protected _pickRectBlack: HTMLDivElement;
protected _pickRectHandle: HTMLDivElement;
protected _pickHue: HTMLDivElement;
protected _pickHuePointerId: number = null;
protected _pickHueHandle: HTMLDivElement;
protected _pickOpacity: HTMLDivElement;
protected _pickOpacityPointerId: number = null;
protected _pickOpacityHandle: HTMLDivElement;
protected _panelFields: HTMLDivElement;
protected _evtColorPick: EventHandle = null;
protected _evtColorToPicker: EventHandle = null;
protected _evtColorPickStart: EventHandle = null;
protected _evtColorPickEnd: EventHandle = null;
protected _fieldHex: TextInput;
protected _renderChanges: boolean;
/**
* Creates a new ColorPicker.
*
* @param args - The arguments.
*/
constructor(args: Readonly<ColorPickerArgs> = {}) {
super(args);
this.class.add(CLASS_ROOT, CLASS_NOT_FLEXIBLE);
// this element shows the actual color. The
// parent element shows the checkerboard pattern
this._domColor = document.createElement('div');
this.dom.appendChild(this._domColor);
this.dom.addEventListener('keydown', this._onKeyDown);
this.dom.addEventListener('focus', this._onFocus);
this.dom.addEventListener('blur', this._onBlur);
this.dom.addEventListener('pointerdown', (evt) => {
if (this.enabled && !this.readOnly && this._overlay.hidden) {
this._openColorPicker();
evt.stopPropagation();
evt.preventDefault();
}
});
this._value = args.value ?? [0, 0, 255, 1];
this._channels = args.channels ?? 3;
this._setValue(this._value);
this.renderChanges = args.renderChanges ?? false;
this.on('change', () => {
if (this.renderChanges) {
this.flash();
}
});
// overlay
this._overlay = new Overlay({
class: 'picker-color',
clickable: true,
hidden: true,
transparent: true
});
this.dom.appendChild(this._overlay.dom);
// rectangular picker
this._pickRect = document.createElement('div');
this._pickRect.classList.add('pick-rect');
this._overlay.append(this._pickRect);
// color drag events
this._pickRect.addEventListener('pointerdown', this._pickRectPointerDown);
this._pickRect.addEventListener('pointermove', this._pickRectPointerMove);
this._pickRect.addEventListener('pointerup', this._pickRectPointerUp);
// white
this._pickRectWhite = document.createElement('div');
this._pickRectWhite.classList.add('white');
this._pickRect.appendChild(this._pickRectWhite);
// black
this._pickRectBlack = document.createElement('div');
this._pickRectBlack.classList.add('black');
this._pickRect.appendChild(this._pickRectBlack);
// handle
this._pickRectHandle = document.createElement('div');
this._pickRectHandle.classList.add('handle');
this._pickRect.appendChild(this._pickRectHandle);
// hue (rainbow) picker
this._pickHue = document.createElement('div');
this._pickHue.classList.add('pick-hue');
this._overlay.append(this._pickHue);
// hue drag events
this._pickHue.addEventListener('pointerdown', this._pickHuePointerDown);
this._pickHue.addEventListener('pointermove', this._pickHuePointerMove);
this._pickHue.addEventListener('pointerup', this._pickHuePointerUp);
// handle
this._pickHueHandle = document.createElement('div');
this._pickHueHandle.classList.add('handle');
this._pickHue.appendChild(this._pickHueHandle);
// opacity (gradient) picker
this._pickOpacity = document.createElement('div');
this._pickOpacity.classList.add('pick-opacity');
this._overlay.append(this._pickOpacity);
// opacity drag events
this._pickOpacity.addEventListener('pointerdown', this._pickOpacityPointerDown);
this._pickOpacity.addEventListener('pointermove', this._pickOpacityPointerMove);
this._pickOpacity.addEventListener('pointerup', this._pickOpacityPointerUp);
// handle
this._pickOpacityHandle = document.createElement('div');
this._pickOpacityHandle.classList.add('handle');
this._pickOpacity.appendChild(this._pickOpacityHandle);
// fields
this._panelFields = document.createElement('div');
this._panelFields.classList.add('fields');
this._overlay.append(this._panelFields);
this._overlay.on('hide', () => {
this._evtColorPick.unbind();
this._evtColorPick = null;
this._evtColorToPicker.unbind();
this._evtColorToPicker = null;
this._evtColorPickStart.unbind();
this._evtColorPickStart = null;
this._evtColorPickEnd.unbind();
this._evtColorPickEnd = null;
});
const createChannelInput = (channel: string) => {
return new NumericInput({
class: ['field', `field-${channel}`],
precision: 0,
step: 1,
min: 0,
max: 255,
placeholder: channel
});
};
// R
const fieldR = createChannelInput('r');
fieldR.on('change', () => {
this._onChangeRgb();
});
this._pickerChannels.push(fieldR);
this._panelFields.appendChild(fieldR.dom);
// G
const fieldG = createChannelInput('g');
fieldG.on('change', () => {
this._onChangeRgb();
});
this._pickerChannels.push(fieldG);
this._panelFields.appendChild(fieldG.dom);
// B
const fieldB = createChannelInput('b');
fieldB.on('change', () => {
this._onChangeRgb();
});
this._pickerChannels.push(fieldB);
this._panelFields.appendChild(fieldB.dom);
// A
const fieldA = createChannelInput('a');
fieldA.on('change', (value: number) => {
this._onChangeAlpha(value);
});
this._pickerChannels.push(fieldA);
this._panelFields.appendChild(fieldA.dom);
// HEX
this._fieldHex = new TextInput({
class: ['field', 'field-hex'],
placeholder: '#'
});
this._fieldHex.on('change', () => {
this._onChangeHex();
});
this._panelFields.appendChild(this._fieldHex.dom);
}
destroy() {
if (this._destroyed) return;
this.dom.removeEventListener('keydown', this._onKeyDown);
this.dom.removeEventListener('focus', this._onFocus);
this.dom.removeEventListener('blur', this._onBlur);
this._pickRect.removeEventListener('pointerdown', this._pickRectPointerDown);
this._pickRect.removeEventListener('pointermove', this._pickRectPointerMove);
this._pickRect.removeEventListener('pointerup', this._pickRectPointerUp);
this._pickHue.removeEventListener('pointerdown', this._pickHuePointerDown);
this._pickHue.removeEventListener('pointermove', this._pickHuePointerMove);
this._pickHue.removeEventListener('pointerup', this._pickHuePointerUp);
this._pickOpacity.removeEventListener('pointerdown', this._pickOpacityPointerDown);
this._pickOpacity.removeEventListener('pointermove', this._pickOpacityPointerMove);
this._pickOpacity.removeEventListener('pointerup', this._pickOpacityPointerUp);
super.destroy();
}
focus() {
this.dom.focus();
}
blur() {
this.dom.blur();
}
protected _onKeyDown = (evt: KeyboardEvent) => {
// escape blurs the field
if (evt.key === 'Escape') {
this.blur();
}
// enter opens the color picker
if (evt.key !== 'Enter' || !this.enabled || this.readOnly) {
return;
}
evt.stopPropagation();
evt.preventDefault();
};
protected _onFocus = (evt: FocusEvent) => {
this.emit('focus');
};
protected _onBlur = (evt: FocusEvent) => {
this.emit('blur');
};
protected _setColorPickerPosition(x: number, y: number) {
this._overlay.position(x, y);
}
protected _setPickerColor(color: number[]) {
if (this._changing || this._dragging) {
return;
}
if (this._channelsNumber >= 3) {
const hsv = _rgb2hsv(color);
this._colorHSV[0] = hsv[0];
this._colorHSV[1] = hsv[1];
this._colorHSV[2] = hsv[2];
}
// set fields
this._directInput = false;
for (let i = 0; i < color.length; i++) {
this._pickerChannels[i].value = color[i];
}
this._fieldHex.value = this._getHex();
this._directInput = true;
}
protected _openColorPicker() {
// open color picker
this._callPicker(this.value.map(c => Math.floor(c * 255)));
// picked color
this._evtColorPick = this.on('picker:color', (color) => {
this.value = color.map((c: number) => c / 255);
});
this._evtColorPickStart = this.on('picker:color:start', () => {
if (this.binding) {
this._historyCombine = this.binding.historyCombine;
this._historyPostfix = this.binding.historyPostfix;
this.binding.historyCombine = true;
this._binding.historyPostfix = `(${Date.now()})`;
} else {
this._historyCombine = false;
this._historyPostfix = null;
}
});
this._evtColorPickEnd = this.on('picker:color:end', () => {
if (this.binding) {
this.binding.historyCombine = this._historyCombine;
this.binding.historyPostfix = this._historyPostfix;
}
});
// position picker
const rectPicker = this._overlay.dom.getBoundingClientRect();
const rectElement = this.dom.getBoundingClientRect();
this._setColorPickerPosition(rectElement.left - rectPicker.left, rectElement.bottom - rectPicker.top + 1);
// color changed, update picker
this._evtColorToPicker = this.on('change', () => {
this._setPickerColor(this.value.map(c => Math.floor(c * 255)));
});
}
protected _callPicker(color: number[]) {
// class for channels
for (let i = 0; i < 4; i++) {
if (color.length - 1 < i) {
this._overlay.class.remove(`c-${i + 1}`);
} else {
this._overlay.class.add(`c-${i + 1}`);
}
}
// number of channels
this._channelsNumber = color.length;
if (this._channelsNumber >= 3) {
const hsv = _rgb2hsv(color);
this._colorHSV[0] = hsv[0];
this._colorHSV[1] = hsv[1];
this._colorHSV[2] = hsv[2];
}
// set fields
this._directInput = false;
for (let i = 0; i < color.length; i++) {
this._pickerChannels[i].value = color[i];
}
this._fieldHex.value = this._getHex();
this._directInput = true;
// show overlay
this._overlay.hidden = false;
// focus on hex field
this._fieldHex.dom.focus();
window.setTimeout(() => {
this._fieldHex.dom.focus();
}, 100);
}
protected _valueToColor(value: number) {
value = Math.floor(value * 255);
return Math.max(0, Math.min(value, 255));
}
protected _setValue(value: number[]) {
const r = this._valueToColor(value[0]);
const g = this._valueToColor(value[1]);
const b = this._valueToColor(value[2]);
const a = value[3];
if (this._channels === 1) {
this._domColor.style.backgroundColor = `rgb(${r}, ${r}, ${r})`;
} else if (this._channels === 3) {
this._domColor.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
} else if (this._channels === 4) {
this._domColor.style.backgroundColor = `rgba(${r}, ${g}, ${b}, ${a})`;
}
}
protected _updateValue(value: number[]) {
let dirty = false;
for (let i = 0; i < value.length; i++) {
if (this._value[i] !== value[i]) {
dirty = true;
this._value[i] = value[i];
}
}
this.class.remove(CLASS_MULTIPLE_VALUES);
if (dirty) {
this._setValue(value);
this.emit('change', value);
}
return dirty;
}
// get hex from channels
protected _getHex() {
let hex = '';
for (let i = 0; i < this._channelsNumber; i++) {
hex += (`00${this._pickerChannels[i].value.toString(16)}`).slice(-2).toUpperCase();
}
return hex;
}
// rect drag start
protected _pickRectPointerDown = (event: PointerEvent) => {
if (this._pickRectPointerId !== null) return;
this._pickRect.setPointerCapture(event.pointerId);
this._pickRectPointerId = event.pointerId;
event.preventDefault();
event.stopPropagation();
this.emit('picker:color:start');
this._pickRectPointerMove(event);
};
// rect drag
protected _pickRectPointerMove = (event: PointerEvent) => {
if (this._pickRectPointerId !== event.pointerId) return;
this._changing = true;
// Get the pointer position relative to the element
const rect = this._pickRect.getBoundingClientRect();
const x = Math.max(0, Math.min(this._size, Math.floor(event.clientX - rect.left)));
const y = Math.max(0, Math.min(this._size, Math.floor(event.clientY - rect.top)));
this._colorHSV[1] = x / this._size;
this._colorHSV[2] = 1.0 - (y / this._size);
this._directInput = false;
const rgb = _hsv2rgb(this._colorHSV);
for (let i = 0; i < 3; i++) {
this._pickerChannels[i].value = rgb[i];
}
this._fieldHex.value = this._getHex();
this._directInput = true;
this._pickRectHandle.style.left = `${Math.max(4, Math.min(this._size - 4, x))}px`;
this._pickRectHandle.style.top = `${Math.max(4, Math.min(this._size - 4, y))}px`;
this._changing = false;
};
// rect drag stop
protected _pickRectPointerUp = (event: PointerEvent) => {
if (this._pickRectPointerId !== event.pointerId) return;
this.emit('picker:color:end');
// Release the pointer
this._pickRect.releasePointerCapture(event.pointerId);
this._pickRectPointerId = null;
};
// hue drag start
protected _pickHuePointerDown = (event: PointerEvent) => {
if (this._pickHuePointerId !== null) return;
this._pickHue.setPointerCapture(event.pointerId);
this._pickHuePointerId = event.pointerId;
event.preventDefault();
event.stopPropagation();
this.emit('picker:color:start');
this._pickHuePointerMove(event);
};
// hue drag
protected _pickHuePointerMove = (event: PointerEvent) => {
if (this._pickHuePointerId !== event.pointerId) return;
this._changing = true;
const rect = this._pickHue.getBoundingClientRect();
const y = Math.max(0, Math.min(this._size, Math.floor(event.clientY - rect.top)));
const h = y / this._size;
const rgb = _hsv2rgb([h, this._colorHSV[1], this._colorHSV[2]]);
this._colorHSV[0] = h;
this._directInput = false;
for (let i = 0; i < 3; i++) {
this._pickerChannels[i].value = rgb[i];
}
this._fieldHex.value = this._getHex();
this._onChangeRgb();
this._directInput = true;
this._changing = false;
};
// hue drag stop
protected _pickHuePointerUp = (event: PointerEvent) => {
if (this._pickHuePointerId !== event.pointerId) return;
this.emit('picker:color:end');
// Release the pointer
this._pickHue.releasePointerCapture(event.pointerId);
this._pickHuePointerId = null;
};
// opacity drag start
protected _pickOpacityPointerDown = (event: PointerEvent) => {
if (this._pickOpacityPointerId !== null) return;
this._pickOpacity.setPointerCapture(event.pointerId);
this._pickOpacityPointerId = event.pointerId;
event.preventDefault();
event.stopPropagation();
this.emit('picker:color:start');
this._pickOpacityPointerMove(event);
};
// opacity drag
protected _pickOpacityPointerMove = (event: PointerEvent) => {
if (this._pickOpacityPointerId !== event.pointerId) return;
this._changing = true;
const rect = this._pickOpacity.getBoundingClientRect();
const y = Math.max(0, Math.min(this._size, Math.floor(event.clientY - rect.top)));
const o = 1.0 - y / this._size;
this._directInput = false;
this._pickerChannels[3].value = Math.max(0, Math.min(255, Math.round(o * 255)));
this._fieldHex.value = this._getHex();
this._directInput = true;
this._changing = false;
};
// opacity drag stop
protected _pickOpacityPointerUp = (event: PointerEvent) => {
if (this._pickOpacityPointerId !== event.pointerId) return;
this.emit('picker:color:end');
// Release the pointer
this._pickOpacity.releasePointerCapture(event.pointerId);
this._pickOpacityPointerId = null;
};
protected _onChangeHex() {
if (!this._directInput) {
return;
}
this._changing = true;
const hex = this._fieldHex.value.trim().toLowerCase();
/* eslint-disable-next-line regexp/no-unused-capturing-group */
if (/^([0-9a-f]{2}){3,4}$/.test(hex)) {
for (let i = 0; i < this._channelsNumber; i++) {
this._pickerChannels[i].value = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
}
this._changing = false;
}
protected _onChangeRgb() {
const color = this._pickerChannels.map((channel) => {
return channel.value || 0;
}).slice(0, this._channelsNumber);
const hsv = _rgb2hsv(color);
if (this._directInput) {
const sum = color[0] + color[1] + color[2];
if (sum !== 765 && sum !== 0) {
this._colorHSV[0] = hsv[0];
}
this._colorHSV[1] = hsv[1];
this._colorHSV[2] = hsv[2];
this._dragging = true;
this.emit('picker:color:start');
this._fieldHex.value = this._getHex();
}
// hue position
this._pickHueHandle.style.top = `${Math.floor(this._size * this._colorHSV[0])}px`; // h
// rect position
this._pickRectHandle.style.left = `${Math.max(4, Math.min(this._size - 4, this._size * this._colorHSV[1]))}px`; // s
this._pickRectHandle.style.top = `${Math.max(4, Math.min(this._size - 4, this._size * (1.0 - this._colorHSV[2])))}px`; // v
if (this._channelsNumber >= 3) {
const plainColor = _hsv2rgb([this._colorHSV[0], 1, 1]).join(',');
// rect background color
this._pickRect.style.backgroundColor = `rgb(${plainColor})`;
// rect handle color
this._pickRectHandle.style.backgroundColor = `rgb(${color.slice(0, 3).join(',')})`;
// hue handle color
this._pickHueHandle.style.backgroundColor = `rgb(${plainColor})`;
}
this.callCallback();
}
// update alpha handle
protected _onChangeAlpha(value: number) {
if (this._channelsNumber !== 4) {
return;
}
// position
this._pickOpacityHandle.style.top = `${Math.floor(this._size * (1.0 - (Math.max(0, Math.min(255, value)) / 255)))}px`;
// color
this._pickOpacityHandle.style.backgroundColor = `rgb(${value}, ${value}, ${value})`;
this.callCallback();
}
/** @ignore */
callbackHandle() {
this._callingCallback = false;
this.emit('picker:color', this._pickerChannels.map((channel) => {
return channel.value || 0;
}).slice(0, this._channelsNumber));
}
/** @ignore */
callCallback() {
if (this._callingCallback) {
return;
}
this._callingCallback = true;
window.setTimeout(() => {
this.callbackHandle();
}, 1000 / 60);
}
set value(value) {
value = value || [0, 0, 0, 0];
const changed = this._updateValue(value);
if (changed && this._binding) {
this._binding.setValue(value);
}
}
get value() {
return this._value.slice(0, this._channels);
}
/* eslint accessor-pairs: 0 */
set values(values: Array<number>) {
let different = false;
const value = values[0];
for (let i = 1; i < values.length; i++) {
if (Array.isArray(value)) {
// @ts-ignore
if (!value.equals(values[i])) { // TODO: check if this works
different = true;
break;
}
} else {
if (value !== values[i]) {
different = true;
break;
}
}
}
if (different) {
this.value = null;
this.class.add(CLASS_MULTIPLE_VALUES);
} else {
// @ts-ignore
this.value = values[0];
}
}
set channels(value) {
if (this._channels === value) return;
this._channels = Math.max(0, Math.min(value, 4));
this._setValue(this.value);
}
get channels() {
return this._channels;
}
set renderChanges(value: boolean) {
this._renderChanges = value;
}
get renderChanges() {
return this._renderChanges;
}
}
Element.register('rgb', ColorPicker);
Element.register('rgba', ColorPicker, { channels: 4 });
export { ColorPicker, ColorPickerArgs };
@@ -0,0 +1,263 @@
// color input
.pcui-color-input {
@extend .pcui-no-select;
position: relative;
display: inline-block;
box-sizing: border-box;
width: 44px;
height: 24px;
margin: $element-margin;
vertical-align: top;
cursor: pointer;
transition: opacity 100ms;
> .pcui-overlay-clickable {
position: fixed;
}
> div {
position: absolute;
inset: 0;
}
// checkerboard pattern
// @include checkerboard(#fff, black, calc(44px/5));
&::after {
content: '\00a0';
position: absolute;
bottom: 0;
right: 0;
width: 0;
height: 0;
background-color: transparent;
border-bottom: 16px solid $bcg-darker;
border-left: 16px solid transparent;
}
}
.picker-color {
&.c-1 > .pcui-overlay-content {
> .pick-opacity {
display: block;
}
> .fields > .field-r {
display: block;
}
}
&.c-2 > .pcui-overlay-content {
> .fields > .field-hex {
display: block;
}
}
&.c-3 > .pcui-overlay-content {
width: 298px;
> .pick-rect {
display: block;
}
> .pick-hue {
display: block;
}
> .pick-opacity {
display: none;
}
> .fields {
> .field-g,
> .field-b {
display: block;
}
}
}
&.c-4 > .pcui-overlay-content {
width: 320px;
> .pick-opacity {
display: block;
}
> .fields > .field-a {
display: block;
}
}
> .pcui-overlay-content {
border: 1px solid #000;
width: 320px;
height: 162px;
// background-color: #333;
white-space: nowrap;
transition: none;
> .pick-rect {
@extend .pcui-no-select;
position: relative;
display: none;
float: left;
width: 146px;
height: 146px;
border: 1px solid #000;
box-sizing: border-box;
margin: 8px 0 8px 8px;
background-color: #f00;
touch-action: none;
cursor: crosshair;
> .white {
position: absolute;
width: 144px;
height: 144px;
top: 0;
left: 0;
background: linear-gradient(to right, rgb(255 255 255 / 100%) 0%, rgb(255 255 255 / 1%) 100%);
}
> .black {
position: absolute;
width: 144px;
height: 144px;
top: 0;
left: 0;
background: linear-gradient(to bottom, rgb(0 0 0 / 1%) 0%, rgb(0 0 0 / 100%) 100%);
}
> .handle {
position: absolute;
top: 0;
left: 0;
width: 12px;
height: 12px;
margin: -7px 0 0 -7px;
border: 1px solid #000;
outline: 1px solid #fff;
}
}
> .pick-hue {
@extend .pcui-no-select;
position: relative;
display: none;
float: left;
width: 14px;
height: 146px;
margin: 8px 0 8px 8px;
border: 1px solid #000;
box-sizing: border-box;
touch-action: none;
cursor: crosshair;
background: #000;
background:
linear-gradient(
to bottom,
rgb(255 0 0 / 100%) 0%,
rgb(255 255 0 / 100%) 16.67%,
rgb(0 255 0 / 100%) 33.33%,
rgb(0 255 255 / 100%) 50%,
rgb(0 0 255 / 100%) 66.67%,
rgb(255 0 255 / 100%) 83.33%,
rgb(255 0 0 / 100%) 100%
);
> .handle {
position: absolute;
top: 0;
left: -3px;
width: 16px;
height: 4px;
margin: -3px 0 0;
border: 1px solid #000;
outline: 1px solid #fff;
}
}
> .pick-opacity {
@extend .pcui-no-select;
position: relative;
display: none;
float: left;
width: 12px;
height: 144px;
margin: 8px 0 8px 8px;
border: 1px solid #000;
touch-action: none;
cursor: crosshair;
background: #000;
background: linear-gradient(to bottom, #fff 0%, #000 100%);
> .handle {
position: absolute;
top: 0;
left: -3px;
width: 16px;
height: 4px;
margin: -3px 0 0;
border: 1px solid #000;
outline: 1px solid #fff;
}
}
> .fields {
float: left;
width: 106px;
height: 154px;
margin: 0 0 0 8px;
padding: 4px;
> .field {
display: none;
width: calc(106px - 6px);
}
}
}
}
.pcui-color-input.pcui-multiple-values {
> div {
display: none;
}
// @include checkerboard(#465659, #5b696c, calc(44px/5));
}
// readonly color input
.pcui-color-input.pcui-readonly {
cursor: default;
&::after {
display: none;
}
}
// disabled color input
.pcui-color-input.pcui-disabled {
opacity: $element-opacity-disabled;
cursor: default;
}
// hover / focus states
.pcui-color-input:not(.pcui-disabled, .pcui-readonly) {
&:hover,
&:focus {
box-shadow: $element-shadow-hover;
&::after {
border-bottom-color: $bcg-darkest;
}
}
&:active {
box-shadow: $element-shadow-active;
}
}
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import { Container } from './component';
import { Label } from '../Label/component';
import '../../scss/index.js';
const meta: Meta<typeof Container> = {
title: 'Components/Container',
component: Container
};
export default meta;
type Story = StoryObj<typeof Container>;
export const Main: Story = {
render: args => <Container {...args}>
<Label text="This is a container with..." />
<Label text="two labels inside" />
</Container>
};
@@ -0,0 +1,61 @@
import * as React from 'react';
import { Element } from '../Element/component';
import { Container as ContainerClass, ContainerArgs } from './index';
// Define interface for child props
interface ContainerChildProps {
parent: ContainerClass;
}
/**
* A container is the basic building block for Elements that are grouped together.
* A container can contain any other element including other containers.
*/
class Container extends Element<ContainerArgs, any> {
declare element: ContainerClass;
constructor(props: ContainerArgs = {}) {
super(props);
this.elementClass = ContainerClass;
}
componentDidMount() {
if (this.props.onResize) {
this.element.on('resize', this.props.onResize);
}
}
getParent = () => {
return this;
};
render() {
const elementsArray = Array.from(React.Children.toArray(this.props.children));
let elements: React.ReactNode;
if (elementsArray.length === 1) {
elements = React.cloneElement(
elementsArray[0] as React.ReactElement<ContainerChildProps>,
{ parent: this.element }
);
} else if (elementsArray.length > 0) {
elements = elementsArray.map(element =>
React.cloneElement(
element as React.ReactElement<ContainerChildProps>,
{ parent: this.element }
)
);
}
// @ts-ignore
return <div ref={this.attachElement}>
{ elements }
</div>;
}
}
Container.ctor = ContainerClass;
export { Container };
@@ -0,0 +1,787 @@
import { CLASS_FLEX, CLASS_GRID, CLASS_RESIZABLE, CLASS_SCROLLABLE } from '../../class';
import { Element, ElementArgs, IFlexArgs, IParentArgs } from '../Element';
const RESIZE_HANDLE_SIZE = 4;
const VALID_RESIZABLE_VALUES = [
null,
'top',
'right',
'bottom',
'left'
];
const CLASS_RESIZING = `${CLASS_RESIZABLE}-resizing`;
const CLASS_RESIZABLE_HANDLE = 'pcui-resizable-handle';
const CLASS_CONTAINER = 'pcui-container';
const CLASS_DRAGGED = `${CLASS_CONTAINER}-dragged`;
const CLASS_DRAGGED_CHILD = `${CLASS_DRAGGED}-child`;
/**
* The arguments for the {@link Container} constructor.
*/
interface ContainerArgs extends ElementArgs, IParentArgs, IFlexArgs {
/**
* Sets whether the {@link Container} is resizable and where the resize handle is located. Can
* be one of 'top', 'bottom', 'right', 'left'. Defaults to `null` which disables resizing.
*/
resizable?: string,
/**
* Sets the minimum size the {@link Container} can take when resized in pixels.
*/
resizeMin?: number,
/**
* Sets the maximum size the {@link Container} can take when resized in pixels.
*/
resizeMax?: number,
/**
* Called when the {@link Container} has been resized.
*/
onResize?: () => void,
/**
* Sets whether the {@link Container} should be scrollable.
*/
scrollable?: boolean,
/**
* Sets whether the {@link Container} supports the grid layout.
*/
grid?: boolean,
/**
* Sets the {@link Container}'s align items property.
*/
alignItems?: string
}
/**
* A container is the basic building block for {@link Element}s that are grouped together. A
* container can contain any other element including other containers.
*/
class Container extends Element {
/**
* Fired when a child Element gets added to the Container.
*
* @event
* @example
* ```ts
* const container = new Container();
* container.on('append', (element: Element) => {
* console.log('Element added to container:', element);
* });
* ```
*/
public static readonly EVENT_APPEND = 'append';
/**
* Fired when a child Element gets removed from the Container.
*
* @event
* @example
* ```ts
* const container = new Container();
* container.on('remove', (element: Element) => {
* console.log('Element removed from container:', element);
* });
* ```
*/
public static readonly EVENT_REMOVE = 'remove';
/**
* Fired when the container is scrolled. The native DOM scroll event is passed to the event handler.
*
* @event
* @example
* ```ts
* const container = new Container();
* container.on('scroll', (event: Event) => {
* console.log('Container scrolled:', event);
* });
* ```
*/
public static readonly EVENT_SCROLL = 'scroll';
/**
* Fired when the container gets resized using the resize handle.
*
* @event
* @example
* ```ts
* const container = new Container();
* container.on('resize', () => {
* console.log('Container resized to:', container.width, container.height, 'px');
* });
* ```
*/
public static readonly EVENT_RESIZE = 'resize';
protected _scrollable = false;
protected _flex = false;
protected _grid = false;
protected _domResizeHandle: HTMLDivElement = null;
protected _resizePointerId: number = null;
protected _resizeData: { x: number, y: number, width: number, height: number } = null;
protected _resizeHorizontally = true;
protected _resizeMin = 100;
protected _resizeMax = 300;
protected _draggedStartIndex = -1;
protected _domContent: HTMLElement;
protected _resizable: string;
/**
* Creates a new Container.
*
* @param args - The arguments.
*/
constructor(args: Readonly<ContainerArgs> = {}) {
super(args);
this.class.add(CLASS_CONTAINER);
this.domContent = this._dom;
// scroll
if (args.scrollable) {
this.scrollable = true;
}
// flex
this.flex = !!args.flex;
// grid
let grid = !!args.grid;
if (grid) {
if (this.flex) {
console.error('Invalid Container arguments: "grid" and "flex" cannot both be true.');
grid = false;
}
}
this.grid = grid;
// resize related
this.resizable = args.resizable ?? null;
if (args.resizeMin !== undefined) {
this.resizeMin = args.resizeMin;
}
if (args.resizeMax !== undefined) {
this.resizeMax = args.resizeMax;
}
}
destroy() {
if (this._destroyed) return;
this.domContent = null;
if (this._domResizeHandle) {
this._domResizeHandle.removeEventListener('pointerdown', this._onResizeStart);
this._domResizeHandle.removeEventListener('pointermove', this._onResizeMove);
this._domResizeHandle.removeEventListener('pointerup', this._onResizeEnd);
}
super.destroy();
}
/**
* Appends an element to the container.
*
* @param {Element} element - The element to append.
*/
append(element: any) {
const dom = this._getDomFromElement(element);
this._domContent.appendChild(dom);
this._onAppendChild(element);
}
/**
* Appends an element to the container before the specified reference element.
*
* @param {Element} element - The element to append.
* @param {Element} referenceElement - The element before which the element will be appended.
*/
appendBefore(element: any, referenceElement: any) {
const dom = this._getDomFromElement(element);
this._domContent.appendChild(dom);
const referenceDom = referenceElement && this._getDomFromElement(referenceElement);
this._domContent.insertBefore(dom, referenceDom);
this._onAppendChild(element);
}
/**
* Appends an element to the container just after the specified reference element.
*
* @param {Element} element - The element to append.
* @param {Element} referenceElement - The element after which the element will be appended.
*/
appendAfter(element: any, referenceElement: any) {
const dom = this._getDomFromElement(element);
const referenceDom = referenceElement && this._getDomFromElement(referenceElement);
const elementBefore = referenceDom ? referenceDom.nextSibling : null;
if (elementBefore) {
this._domContent.insertBefore(dom, elementBefore);
} else {
this._domContent.appendChild(dom);
}
this._onAppendChild(element);
}
/**
* Inserts an element in the beginning of the container.
*
* @param {Element} element - The element to prepend.
*/
prepend(element: any) {
const dom = this._getDomFromElement(element);
const first = this._domContent.firstChild;
if (first) {
this._domContent.insertBefore(dom, first);
} else {
this._domContent.appendChild(dom);
}
this._onAppendChild(element);
}
/**
* Removes the specified child element from the container.
*
* @param element - The element to remove.
*/
remove(element: Element) {
if (element.parent !== this) return;
const dom = this._getDomFromElement(element);
this._domContent.removeChild(dom);
this._onRemoveChild(element);
}
/**
* Moves the specified child at the specified index.
*
* @param element - The element to move.
* @param index - The index to move the element to.
*/
move(element: Element, index: number) {
const idx = Array.prototype.indexOf.call(this.dom.childNodes, element.dom);
if (idx === -1) {
this.appendBefore(element, this.dom.childNodes[index]);
} else if (index !== idx) {
this.remove(element);
if (index < idx) {
this.appendBefore(element, this.dom.childNodes[index]);
} else {
this.appendAfter(element, this.dom.childNodes[index - 1]);
}
}
}
/**
* Clears all children from the container.
*/
clear() {
let i = this._domContent.childNodes.length;
while (i--) {
const node = this._domContent.childNodes[i];
if (node.ui && node.ui !== this) {
node.ui.destroy();
}
}
if (this._domResizeHandle) {
this._domResizeHandle.removeEventListener('pointerdown', this._onResizeStart);
this._domResizeHandle.removeEventListener('pointermove', this._onResizeMove);
this._domResizeHandle.removeEventListener('pointerup', this._onResizeEnd);
this._domResizeHandle = null;
}
this._domContent.innerHTML = '';
if (this.resizable) {
this._createResizeHandle();
this._dom.appendChild(this._domResizeHandle);
}
}
// Used for backwards compatibility with the legacy ui framework
protected _getDomFromElement(element: any) {
if (element.dom) {
return element.dom;
}
if (element.element) {
// console.log('Legacy ui.Element passed to Container', this.class, element.class);
return element.element;
}
return element;
}
protected _onAppendChild(element: Element) {
element.parent = this;
this.emit('append', element);
}
protected _onRemoveChild(element: Element) {
element.parent = null;
this.emit('remove', element);
}
protected _onScroll = (evt: Event) => {
this.emit('scroll', evt);
};
protected _createResizeHandle() {
const handle = document.createElement('div');
handle.classList.add(CLASS_RESIZABLE_HANDLE);
handle.ui = this;
handle.addEventListener('pointerdown', this._onResizeStart);
handle.addEventListener('pointermove', this._onResizeMove);
handle.addEventListener('pointerup', this._onResizeEnd);
this._domResizeHandle = handle;
}
protected _onResizeStart = (evt: PointerEvent) => {
if (this._resizePointerId !== null) return;
evt.preventDefault();
evt.stopPropagation();
this._domResizeHandle.setPointerCapture(evt.pointerId);
this._resizePointerId = evt.pointerId;
this._resizeStart();
};
protected _onResizeMove = (evt: PointerEvent) => {
if (this._resizePointerId !== evt.pointerId) return;
evt.preventDefault();
evt.stopPropagation();
this._resizeMove(evt.clientX, evt.clientY);
};
protected _onResizeEnd = (evt: PointerEvent) => {
if (this._resizePointerId !== evt.pointerId) return;
evt.preventDefault();
evt.stopPropagation();
this._resizeEnd();
this._domResizeHandle.releasePointerCapture(evt.pointerId);
this._resizePointerId = null;
};
protected _resizeStart() {
this.class.add(CLASS_RESIZING);
}
protected _resizeMove(x: number, y: number) {
// if we haven't initialized resizeData do so now
if (!this._resizeData) {
this._resizeData = {
x: x,
y: y,
width: this.dom.clientWidth,
height: this.dom.clientHeight
};
return;
}
if (this._resizeHorizontally) {
// horizontal resizing
let offsetX = this._resizeData.x - x;
if (this._resizable === 'right') {
offsetX = -offsetX;
}
this.width = RESIZE_HANDLE_SIZE + Math.max(this._resizeMin, Math.min(this._resizeMax, (this._resizeData.width + offsetX)));
} else {
// vertical resizing
let offsetY = this._resizeData.y - y;
if (this._resizable === 'bottom') {
offsetY = -offsetY;
}
this.height = Math.max(this._resizeMin, Math.min(this._resizeMax, (this._resizeData.height + offsetY)));
}
this.emit('resize');
}
protected _resizeEnd() {
this._resizeData = null;
this.class.remove(CLASS_RESIZING);
}
/**
* Resize the container.
*
* @param x - The number of pixels to resize the width.
* @param y - The number of pixels to resize the height.
*/
resize(x = 0, y = 0) {
this._resizeStart();
this._resizeMove(0, 0);
this._resizeMove(-x + RESIZE_HANDLE_SIZE, -y);
this._resizeEnd();
}
protected _getDraggedChildIndex(draggedChild: Element) {
for (let i = 0; i < this.dom.childNodes.length; i++) {
if (this.dom.childNodes[i].ui === draggedChild) {
return i;
}
}
return -1;
}
protected _onChildDragStart(evt: MouseEvent, childPanel: Element) {
this.class.add(CLASS_DRAGGED_CHILD);
this._draggedStartIndex = this._getDraggedChildIndex(childPanel);
childPanel.class.add(CLASS_DRAGGED);
this.emit('child:dragstart', childPanel, this._draggedStartIndex);
}
protected _onChildDragMove(evt: MouseEvent, childPanel: Element) {
const rect = this.dom.getBoundingClientRect();
const dragOut = (evt.clientX < rect.left || evt.clientX > rect.right || evt.clientY < rect.top || evt.clientY > rect.bottom);
const childPanelIndex = this._getDraggedChildIndex(childPanel);
if (dragOut) {
childPanel.class.remove(CLASS_DRAGGED);
if (this._draggedStartIndex !== childPanelIndex) {
this.remove(childPanel);
if (this._draggedStartIndex < childPanelIndex) {
this.appendBefore(childPanel, this.dom.childNodes[this._draggedStartIndex]);
} else {
this.appendAfter(childPanel, this.dom.childNodes[this._draggedStartIndex - 1]);
}
}
return;
}
childPanel.class.add(CLASS_DRAGGED);
const y = evt.clientY - rect.top;
let ind = null;
// hovered script
for (let i = 0; i < this.dom.childNodes.length; i++) {
const otherPanel = this.dom.childNodes[i].ui as any;
const otherTop = otherPanel.dom.offsetTop;
if (i < childPanelIndex) {
if (y <= otherTop + otherPanel.header.height) {
ind = i;
break;
}
} else if (i > childPanelIndex) {
if (y + childPanel.height >= otherTop + otherPanel.height) {
ind = i;
break;
}
}
}
if (ind !== null && childPanelIndex !== ind) {
this.remove(childPanel);
if (ind < childPanelIndex) {
this.appendBefore(childPanel, this.dom.childNodes[ind]);
} else {
this.appendAfter(childPanel, this.dom.childNodes[ind - 1]);
}
}
}
protected _onChildDragEnd(evt: MouseEvent, childPanel: Element) {
this.class.remove(CLASS_DRAGGED_CHILD);
childPanel.class.remove(CLASS_DRAGGED);
const index = this._getDraggedChildIndex(childPanel);
this.emit('child:dragend', childPanel, index, this._draggedStartIndex);
this._draggedStartIndex = -1;
}
/**
* Iterate over each child element using the supplied function. To early out of the iteration,
* return `false` from the function.
*
* @param fn - The function to call for each child element.
*/
forEachChild(fn: (child: Element, index: number) => void | false) {
for (let i = 0; i < this.dom.childNodes.length; i++) {
const node = this.dom.childNodes[i].ui;
if (node) {
const result = fn(node, i);
if (result === false) {
// early out
break;
}
}
}
}
/**
* If the current node contains a root, recursively append its children to this node
* and return it. Otherwise return the current node. Also add each child to the parent
* under its keyed name.
*
* @param node - The current element in the dom structure which must be recursively
* traversed and appended to its parent.
* @param node.root - The root node of the dom structure.
* @param node.children - The children of the root node.
* @returns The recursively appended element node.
*/
protected _buildDomNode(node: { [x: string]: any; root?: any; children?: any; }): Container {
const keys = Object.keys(node);
let rootNode: Container;
if (keys.includes('root')) {
rootNode = this._buildDomNode(node.root);
node.children.forEach((childNode: any) => {
const childNodeElement = this._buildDomNode(childNode);
if (childNodeElement !== null) {
rootNode.append(childNodeElement);
}
});
} else {
rootNode = node[keys[0]];
// @ts-ignore
this[`_${keys[0]}`] = rootNode;
}
return rootNode;
}
/**
* Takes an array of pcui elements, each of which can contain their own child elements, and
* appends them to this container. These child elements are traversed recursively using
* _buildDomNode.
*
* @param dom - An array of child pcui elements to append to this container.
*
* @example
* buildDom([
* {
* child1: pcui.Label()
* },
* {
* root: {
* container1: pcui.Container()
* },
* children: [
* {
* child2: pcui.Label()
* },
* {
* child3: pcui.Label()
* }
* ]
* }
* ]);
*/
buildDom(dom: any[]) {
dom.forEach((node: any) => {
const builtNode = this._buildDomNode(node);
this.append(builtNode);
});
}
/**
* Sets whether the Element supports flex layout.
*/
set flex(value: boolean) {
if (value === this._flex) return;
this._flex = value;
if (value) {
this.class.add(CLASS_FLEX);
} else {
this.class.remove(CLASS_FLEX);
}
}
/**
* Gets whether the Element supports flex layout.
*/
get flex(): boolean {
return this._flex;
}
/**
* Sets whether the Element supports the grid layout.
*/
set grid(value: boolean) {
if (value === this._grid) return;
this._grid = value;
if (value) {
this.class.add(CLASS_GRID);
} else {
this.class.remove(CLASS_GRID);
}
}
/**
* Gets whether the Element supports the grid layout.
*/
get grid(): boolean {
return this._grid;
}
/**
* Sets whether the Element should be scrollable.
*/
set scrollable(value: boolean) {
if (this._scrollable === value) return;
this._scrollable = value;
if (value) {
this.class.add(CLASS_SCROLLABLE);
} else {
this.class.remove(CLASS_SCROLLABLE);
}
}
/**
* Gets whether the Element should be scrollable.
*/
get scrollable(): boolean {
return this._scrollable;
}
/**
* Sets whether the Element is resizable and where the resize handle is located. Can be one of
* 'top', 'bottom', 'right', 'left'. Set to null to disable resizing.
*/
set resizable(value: string) {
if (value === this._resizable) return;
if (VALID_RESIZABLE_VALUES.indexOf(value) === -1) {
console.error(`Invalid resizable value: must be one of ${VALID_RESIZABLE_VALUES.join(',')}`);
return;
}
// remove old class
if (this._resizable) {
this.class.remove(`${CLASS_RESIZABLE}-${this._resizable}`);
}
this._resizable = value;
this._resizeHorizontally = (value === 'right' || value === 'left');
if (value) {
// add resize class and create / append resize handle
this.class.add(CLASS_RESIZABLE);
this.class.add(`${CLASS_RESIZABLE}-${value}`);
if (!this._domResizeHandle) {
this._createResizeHandle();
}
this._dom.appendChild(this._domResizeHandle);
} else {
// remove resize class and resize handle
this.class.remove(CLASS_RESIZABLE);
if (this._domResizeHandle) {
this._dom.removeChild(this._domResizeHandle);
}
}
}
/**
* Gets whether the Element is resizable and where the resize handle is located.
*/
get resizable(): string {
return this._resizable;
}
/**
* Sets the minimum size the Element can take when resized in pixels.
*/
set resizeMin(value: number) {
this._resizeMin = Math.max(0, Math.min(value, this._resizeMax));
}
/**
* Gets the minimum size the Element can take when resized in pixels.
*/
get resizeMin(): number {
return this._resizeMin;
}
/**
* Sets the maximum size the Element can take when resized in pixels.
*/
set resizeMax(value: number) {
this._resizeMax = Math.max(this._resizeMin, value);
}
/**
* Gets the maximum size the Element can take when resized in pixels.
*/
get resizeMax(): number {
return this._resizeMax;
}
/**
* Sets the internal DOM element used as a the container of all children. Can be overridden by
* derived classes.
*/
set domContent(value: HTMLElement) {
if (this._domContent === value) return;
if (this._domContent) {
this._domContent.removeEventListener('scroll', this._onScroll);
}
this._domContent = value;
if (this._domContent) {
this._domContent.addEventListener('scroll', this._onScroll);
}
}
/**
* Gets the internal DOM element used as a the container of all children.
*/
get domContent(): HTMLElement {
return this._domContent;
}
}
Element.register('container', Container);
export { Container, ContainerArgs };
@@ -0,0 +1,92 @@
// container element
.pcui-container {
// containers should be relative to each other
position: relative;
min-width: 0; // this fixes a lot of flex / grid issues
min-height: 0; // this fixes a lot of flex / grid issues
}
// resizable content
.pcui-container.pcui-resizable {
// resizable handle
> .pcui-resizable-handle {
position: absolute;
z-index: 1;
opacity: 0;
background-color: transparent;
touch-action: none;
&:hover {
opacity: 1;
}
}
// resizable handle state while resizing
&.pcui-resizable-resizing > .pcui-resizable-handle {
opacity: 1;
}
// horizontal handle
&.pcui-resizable-left,
&.pcui-resizable-right {
> .pcui-resizable-handle {
top: 0;
bottom: 0;
width: 1px;
height: auto;
cursor: ew-resize;
}
}
&.pcui-resizable-left {
> .pcui-resizable-handle {
left: 0;
border-left: 3px solid $bcg-darkest;
}
}
&.pcui-resizable-right {
> .pcui-resizable-handle {
right: 0;
border-right: 3px solid $bcg-darkest;
}
}
// vertical handle
&.pcui-resizable-top,
&.pcui-resizable-bottom {
> .pcui-resizable-handle {
left: 0;
right: 0;
width: auto;
height: 1px;
cursor: ns-resize;
}
}
&.pcui-resizable-top {
> .pcui-resizable-handle {
top: 0;
border-top: 3px solid $bcg-darkest;
}
}
&.pcui-resizable-bottom {
> .pcui-resizable-handle {
bottom: 0;
border-bottom: 3px solid $bcg-darkest;
}
}
}
.pcui-container-dragged {
outline: 2px solid $text-primary;
box-sizing: border-box;
opacity: 0.7;
z-index: 1;
}
.pcui-container-dragged-child {
outline: 1px dotted $text-active;
box-sizing: border-box;
}
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import { Divider } from './component';
import '../../scss/index.js';
const meta: Meta<typeof Divider> = {
title: 'Components/Divider',
component: Divider
};
export default meta;
type Story = StoryObj<typeof Divider>;
export const Main: Story = {
render: args => <Divider {...args} />
};
@@ -0,0 +1,22 @@
import { Element } from '../Element/component';
import { ElementArgs } from '../Element/index';
import { Divider as DividerClass } from './index';
/**
* Represents a vertical division between two elements
*/
class Divider extends Element<ElementArgs, any> {
constructor(props: ElementArgs) {
super(props);
this.elementClass = DividerClass;
}
render() {
return super.render();
}
}
Divider.ctor = DividerClass;
export { Divider };
@@ -0,0 +1,23 @@
import { Element, ElementArgs } from '../Element';
const CLASS_ROOT = 'pcui-divider';
/**
* Represents a vertical division between two elements.
*/
class Divider extends Element {
/**
* Creates a new Divider.
*
* @param args - The arguments.
*/
constructor(args: Readonly<ElementArgs> = {}) {
super(args);
this.class.add(CLASS_ROOT);
}
}
Element.register('divider', Divider);
export { Divider };
@@ -0,0 +1,5 @@
.pcui-divider {
height: 1px;
background-color: $bcg-dark;
margin: $element-margin 0;
}
@@ -0,0 +1,131 @@
import * as React from 'react';
import { Element as ElementClass, ElementArgs } from './index';
/**
* The base class for all UI elements. Wraps a DOM element with the PCUI interface.
*/
class Element<P extends ElementArgs, S> extends React.Component<P, S> {
static ctor: any;
element: ElementClass;
elementClass: typeof ElementClass;
onClick: () => void;
onChange: (value: any) => void;
onRemove: () => void;
link: ElementArgs['link'];
onAttach?: () => void;
class: Set<string>;
constructor(props: P) {
super(props);
this.elementClass = ElementClass;
if (props.onClick) {
this.onClick = props.onClick;
}
if (props.onRemove) {
this.onRemove = props.onRemove;
}
if (props.onChange) {
this.onChange = props.onChange;
}
if (props.link) {
this.link = props.link;
}
}
attachElement = (nodeElement: HTMLElement, containerElement: any) => {
if (!nodeElement) return;
this.element = new this.elementClass({
...this.props,
dom: nodeElement,
content: containerElement,
parent: undefined
});
const c = this.props.class;
this.class = new Set(c ? (Array.isArray(c) ? c.slice() : [c]) : undefined);
if (this.onClick) {
this.element.on('click', this.onClick);
}
if (this.onRemove) {
this.element.on('click:remove', this.onRemove);
}
if (this.onChange) {
this.element.on('change', this.onChange);
}
if (this.props.parent) {
this.element.parent = this.props.parent;
}
if (this.onAttach) {
this.onAttach();
}
};
getPropertyDescriptor = (obj: any, prop: any) => {
let desc;
do {
desc = Object.getOwnPropertyDescriptor(obj, prop);
} while (!desc && (obj = Object.getPrototypeOf(obj)));
return desc;
};
componentDidMount() {
if (this.link) {
this.element.link(this.link.observer, this.link.path);
}
}
componentDidUpdate(prevProps: any) {
Object.keys(this.props).forEach((prop) => {
const propDescriptor = this.getPropertyDescriptor(this.element, prop);
if (propDescriptor && propDescriptor.set) {
if (prop === 'value') {
// @ts-ignore
this.element._suppressChange = true;
// @ts-ignore
this.element[prop] = this.props[prop];
// @ts-ignore
this.element._suppressChange = false;
} else {
// @ts-ignore
this.element[prop] = this.props[prop];
}
} else if (prop === 'class') {
const c = this.props[prop];
const classProp = new Set(c ? (Array.isArray(c) ? c.slice() : [c]) : undefined);
classProp.forEach((cls: string) => {
if (!this.class.has(cls)) {
this.element.class.add(cls);
this.class.add(cls);
}
});
this.class.forEach((cls: string) => {
if (!classProp.has(cls)) {
this.element.class.remove(cls);
this.class.delete(cls);
}
});
}
});
if (prevProps.link !== this.props.link && this.props.link) {
this.element.link(this.props.link.observer, this.props.link.path);
}
}
render() {
// @ts-ignore
return <div ref={this.attachElement} />;
}
}
export { Element };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
// Element styling
@keyframes pcui-flash-animation {
from { outline-color: $text-active; }
to { outline-color: rgba($text-active, 0); }
}
.pcui-element {
@extend .font-regular;
border: 0 solid $border-primary;
&.flash {
outline: 1px solid $text-active;
animation: pcui-flash-animation 200ms ease-in-out forwards;
}
&:focus {
outline: none;
}
// remove dotted border on focused elements in firefox
&::-moz-focus-inner {
border: 0;
}
}
.pcui-element.pcui-hidden {
display: none;
}
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import { GradientPicker } from './component';
import '../../scss/index.js';
const meta: Meta<typeof GradientPicker> = {
title: 'Components/GradientPicker',
component: GradientPicker
};
export default meta;
type Story = StoryObj<typeof GradientPicker>;
export const Main: Story = {
render: args => <GradientPicker {...args} />
};
@@ -0,0 +1,24 @@
import * as React from 'react';
import { Element } from '../Element/component';
import { GradientPicker as GradientPickerClass, GradientPickerArgs } from './index';
/**
* Represents a gradient picker.
*/
class GradientPicker extends Element<GradientPickerArgs, any> {
constructor(props: GradientPickerArgs) {
super(props);
this.elementClass = GradientPickerClass;
}
render() {
// @ts-ignore
return <div ref={this.attachElement}/>;
}
}
GradientPicker.ctor = GradientPickerClass;
export { GradientPicker };
@@ -0,0 +1,289 @@
.pcui-gradient {
display: inline-block;
flex: 1;
height: 24px;
box-sizing: border-box;
margin: $element-margin;
transition: opacity 100ms, box-shadow 100ms;
border: 1px solid $bcg-darker;
background-color: $bcg-dark;
> .pcui-canvas {
width: 100%;
height: 100%;
background-color: transparent;
}
}
// disabled gradient input
.pcui-gradient.pcui-disabled,
.pcui-gradient.pcui-multiple-values {
opacity: $element-opacity-disabled;
}
// hover / focus states
.pcui-gradient:not(.pcui-disabled, .pcui-readonly, .pcui-multiple-values) {
&:hover,
&:focus {
cursor: pointer;
box-shadow: $element-shadow-hover;
}
&:active {
box-shadow: $element-shadow-active;
}
}
.picker-gradient {
> .pcui-overlay-content {
width: 343px;
height: 262px;
> .picker-gradient-panel {
height: 100%;
font-size: 11px;
>.show-selected-position {
position: absolute;
width: 18px;
min-height: 17px !important;
height: 17px !important;
top: 14px;
margin-top: 0;
margin-bottom: 0;
display: flex;
align-items: center;
text-align: center;
color: #9ba1a3;
background-color: #2c393c;
border-radius: 2px;
justify-content: center;
>.show-selected-position-input {
width: inherit;
text-align: center;
justify-content: center;
font-style: normal;
font-weight: 400;
font-size: 12px;
line-height: 22px;
}
}
> .anchor-crosshair {
position: absolute;
top: 41.5px;
pointer-events: none;
background: none;
>.crosshair-bar {
background: #293538;
width: 1px;
height: 29px;
position: absolute;
top: -34px;
left: 8px;
}
>.show-crosshair-position {
font-style: normal;
font-weight: 400;
font-size: 12px;
font-family: inconsolatamedium, Monaco, Menlo, 'Ubuntu Mono', Consolas, source-code-pro, monospace;
line-height: 22px;
position: absolute;
width: 18px;
min-height: 17px !important;
height: 17px !important;
top: 14px;
margin-top: 0;
margin-bottom: 0;
display: flex;
align-items: center;
text-align: center;
color: #9ba1a3;
background-color: #2c393c;
border-radius: 2px;
justify-content: center;
}
}
> .picker-gradient-gradient {
width: 321px;
height: 28px;
display: block;
padding: 8px 10px 0 11px;
background-color: #2c393c;
.crosshair-active {
cursor: none;
}
}
> .picker-gradient-anchors {
width: 320px;
height: 28px;
display: block;
padding: 0 10px 0 11px;
background-color: #2c393c;
}
> .picker-gradient-footer {
padding: 5px;
> .pcui-panel-header {
display: none;
}
> .pcui-panel-content {
display: flex;
> .pcui-label {
align-self: center;
font-family: inherit;
font-style: normal;
font-weight: 600;
font-size: 12px;
line-height: 19px;
align-content: center;
height: 20px;
}
> .pcui-select-input {
align-self: center;
width: 162px;
height: 22px;
}
> .pcui-numeric-input {
align-self: center;
}
> .pcui-button {
width: 22px;
height: 22px;
@extend .font-icon;
vertical-align: bottom;
margin: 0;
margin-right: 8px;
margin-top: 6px;
}
> .copy-curve-button {
border-color: #2c393c;
&::after {
content: '\e351';
position: absolute;
top: 4px;
left: 218px;
font-size: 15px;
text-align: center;
@extend .font-icon;
}
}
> .paste-curve-button {
border-color: #2c393c;
&::after {
content: '\e348';
position: absolute;
top: 4px;
left: 248px;
font-size: 15px;
text-align: center;
@extend .font-icon;
}
}
> .delete-curve-button {
border-color: #2c393c;
&::after {
content: '\e125';
position: absolute;
top: 4px;
left: 278px;
font-size: 15px;
text-align: center;
@extend .font-icon;
}
}
}
}
> .color-panel {
height: 156px;
> .pcui-panel-header {
display: none;
}
> .pcui-panel-content {
> .color-rect {
margin: 5px 10px 10px;
width: 140px;
height: 140px;
cursor: crosshair;
position: relative;
float: left;
border-width: 1px;
}
> .color-handle {
position: absolute;
width: 14px;
height: 14px;
border: 1px solid #000;
outline: 1px solid #fff;
pointer-events: none;
}
> .hue-rect,
> .alpha-rect {
margin: 5px 10px 10px 0;
width: 20px;
height: 140px;
cursor: crosshair;
border-width: 1px;
}
> .hue-handle,
> .alpha-handle {
position: absolute;
width: 20px;
height: 4px;
border: 1px solid rgb(92 82 79);
outline: 1px solid #fff;
pointer-events: none;
}
> .fields {
display: inline-block;
margin: 3px 0 0;
width: 112px;
height: 145px;
vertical-align: top;
> .pcui-numeric-input {
margin: 2px 0;
width: 108px;
}
> .pcui-text-input {
margin: 2px 0;
min-height: 22px;
min-width: 111px;
}
}
}
}
}
}
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import { GridView } from './component';
import { GridViewItem } from '../GridViewItem/component';
import '../../scss/index.js';
const meta: Meta<typeof GridView> = {
title: 'Components/GridView',
component: GridView
};
export default meta;
type Story = StoryObj<typeof GridView>;
export const Main: Story = {
render: args => <GridView {...args}>
<GridViewItem text='Item 1' />
<GridViewItem text='Item 2' />
<GridViewItem text='Item 3' />
<GridViewItem text='Item 4' />
<GridViewItem text='Item 5' />
<GridViewItem text='Item 6' />
<GridViewItem text='Item 7' />
<GridViewItem text='Item 8' />
<GridViewItem text='Item 9' />
</GridView>
};
@@ -0,0 +1,42 @@
import * as React from 'react';
import { Element } from '../Element/component';
import { GridViewItem } from '../GridViewItem/index';
import { GridView as GridViewClass, GridViewArgs } from './index';
/**
* Represents a container that shows a flexible wrappable list of items that looks like a grid.
* Contains GridViewItems.
*/
class GridView extends Element<GridViewArgs, any> {
constructor(props: GridViewArgs) {
super(props);
this.element = new GridViewClass({ ...props });
this.loadChildren(this.props.children, this.element);
}
loadChildren(children: any, element: any) {
if (!children) return;
if (!Array.isArray(children)) {
children = [children];
}
children.forEach((child: any) => {
const childElement = new GridViewItem({ text: child.props.text });
element.append(childElement);
this.loadChildren(child.props.children, childElement);
});
}
render() {
return <div ref={(nodeElement) => {
if (nodeElement) {
nodeElement.appendChild(this.element.dom);
}
}} />;
}
}
GridView.ctor = GridViewClass;
export { GridView };
@@ -0,0 +1,292 @@
import { EventHandle } from '@playcanvas/observer';
import { Container, ContainerArgs } from '../Container';
import { Element } from '../Element';
import { GridViewItem } from '../GridViewItem';
const CLASS_ROOT = 'pcui-gridview';
const CLASS_VERTICAL = `${CLASS_ROOT}-vertical`;
/**
* The arguments for the {@link GridView} constructor.
*/
interface GridViewArgs extends ContainerArgs {
/**
* If `true` the {@link GridView} layout will be vertical.
*/
vertical?: boolean;
/**
* If `true`, the layout will allow for multiple options to be selected. Defaults to `true`.
*/
multiSelect?: boolean;
/**
* If `true` and `multiSelect` is set to `false`, the layout will allow options to be deselected. Defaults to `true`.
*/
allowDeselect?: boolean;
/**
* A filter function to filter {@link GridViewItem}s with signature `(GridViewItem) => boolean`.
*/
filterFn?: (item: GridViewItem) => boolean;
}
/**
* Represents a container that shows a flexible wrappable list of items that looks like a grid.
* Contains {@link GridViewItem}s.
*/
class GridView extends Container {
protected _vertical: boolean;
protected _clickFn: (evt: MouseEvent, item: GridViewItem) => void;
protected _filterFn: (item: GridViewItem) => boolean;
protected _filterAnimationFrame: number = null;
protected _filterCanceled = false;
protected _multiSelect: boolean;
protected _allowDeselect: boolean;
protected _selected: GridViewItem[] = [];
/**
* Creates a new GridView.
*
* @param args - The arguments.
*/
constructor(args: Readonly<GridViewArgs> = {}) {
super(args);
this._vertical = !!args.vertical;
this.class.add(this._vertical ? CLASS_VERTICAL : CLASS_ROOT);
this.on('append', (element: Element) => {
this._onAppendGridViewItem(element as GridViewItem);
});
this.on('remove', (element: Element) => {
this._onRemoveGridViewItem(element as GridViewItem);
});
this._filterFn = args.filterFn;
// Default options for GridView layout
this._multiSelect = args.multiSelect ?? true;
this._allowDeselect = args.allowDeselect ?? true;
}
protected _onAppendGridViewItem(item: GridViewItem) {
if (!(item instanceof GridViewItem)) return;
let evtClick: EventHandle;
if (this._clickFn) {
evtClick = item.on('click', evt => this._clickFn(evt, item));
} else {
evtClick = item.on('click', evt => this._onClickItem(evt, item));
}
let evtSelect = item.on('select', () => this._onSelectItem(item));
let evtDeselect: EventHandle;
if (this._allowDeselect) {
evtDeselect = item.on('deselect', () => this._onDeselectItem(item));
}
if (this._filterFn && !this._filterFn(item)) {
item.hidden = true;
}
item.once('griditem:remove', () => {
evtClick.unbind();
evtClick = null;
evtSelect.unbind();
evtSelect = null;
if (this._allowDeselect) {
evtDeselect.unbind();
evtDeselect = null;
}
});
}
protected _onRemoveGridViewItem(item: GridViewItem) {
if (!(item instanceof GridViewItem)) return;
item.selected = false;
item.emit('griditem:remove');
item.unbind('griditem:remove');
}
protected _onClickItem(evt: MouseEvent, item: GridViewItem) {
if ((evt.ctrlKey || evt.metaKey) && this._multiSelect) {
item.selected = !item.selected;
} else if (evt.shiftKey && this._multiSelect) {
const lastSelected = this._selected[this._selected.length - 1];
if (lastSelected) {
const comparePosition = lastSelected.dom.compareDocumentPosition(item.dom);
if (comparePosition & Node.DOCUMENT_POSITION_FOLLOWING) {
let sibling = lastSelected.nextSibling;
while (sibling) {
sibling.selected = true;
if (sibling === item) break;
sibling = sibling.nextSibling;
}
} else {
let sibling = lastSelected.previousSibling;
while (sibling) {
sibling.selected = true;
if (sibling === item) break;
sibling = sibling.previousSibling;
}
}
} else {
item.selected = true;
}
} else {
let othersSelected = false;
let i = this._selected.length;
while (i--) {
if (this._selected[i] && this._selected[i] !== item) {
this._selected[i].selected = false;
othersSelected = true;
}
}
if (othersSelected) {
item.selected = true;
} else {
item.selected = !item.selected;
}
}
}
protected _onSelectItem(item: GridViewItem) {
this._selected.push(item);
this.emit('select', item);
}
protected _onDeselectItem(item: GridViewItem) {
const index = this._selected.indexOf(item);
if (index !== -1) {
this._selected.splice(index, 1);
this.emit('deselect', item);
}
}
/**
* Deselects all selected grid view items.
*/
deselect() {
let i = this._selected.length;
while (i--) {
if (this._selected[i]) {
this._selected[i].selected = false;
}
}
}
/**
* Filters grid view items with the filter function provided in the constructor.
*/
filter() {
this.forEachChild((child) => {
if (child instanceof GridViewItem) {
child.hidden = this._filterFn && !this._filterFn(child);
}
});
}
/**
* Filters grid view items asynchronously by only allowing up to the specified number of grid
* view item operations. Fires the following events:
*
* - filter:start - When filtering starts.
* - filter:end - When filtering ends.
* - filter:delay - When an animation frame is requested to process another batch.
* - filter:cancel - When filtering is canceled.
*
* @param batchLimit - The maximum number of items to show before requesting another animation frame.
*/
filterAsync(batchLimit = 100) {
let i = 0;
const children = this.dom.childNodes;
const len = children.length;
this.emit('filter:start');
this._filterCanceled = false;
const next = () => {
this._filterAnimationFrame = null;
let visible = 0;
for (; i < len && visible < batchLimit; i++) {
if (this._filterCanceled) {
this._filterCanceled = false;
this.emit('filter:cancel');
return;
}
const child = children[i].ui;
if (child instanceof GridViewItem) {
if (this._filterFn && !this._filterFn(child)) {
child.hidden = true;
} else {
child.hidden = false;
visible++;
}
}
}
if (i < len) {
this.emit('filter:delay');
this._filterAnimationFrame = requestAnimationFrame(next);
} else {
this.emit('filter:end');
}
};
next();
}
/**
* Cancels asynchronous filtering.
*/
filterAsyncCancel() {
if (this._filterAnimationFrame) {
cancelAnimationFrame(this._filterAnimationFrame);
this._filterAnimationFrame = null;
} else {
this._filterCanceled = true;
}
}
destroy() {
if (this._destroyed) return;
if (this._filterAnimationFrame) {
cancelAnimationFrame(this._filterAnimationFrame);
this._filterAnimationFrame = null;
}
super.destroy();
}
/**
* Gets the selected GridViewItems.
*/
get selected() {
return this._selected.slice();
}
/**
* Gets whether the grid layout is vertical or not.
*/
get vertical() {
return this._vertical;
}
}
export { GridView, GridViewArgs };
@@ -0,0 +1,13 @@
.pcui-gridview {
@extend .pcui-flex;
flex-flow: row wrap;
align-content: flex-start;
}
.pcui-gridview-vertical {
@extend .pcui-flex;
flex-direction: column;
align-content: flex-start;
}
@@ -0,0 +1,21 @@
import { Element } from '../Element/component';
import { GridViewItem as GridViewItemClass, GridViewItemArgs } from './index';
/**
* Represents a grid view item used in GridView.
*/
class GridViewItem extends Element<GridViewItemArgs, any> {
constructor(props: GridViewItemArgs) {
super(props);
this.elementClass = GridViewItemClass;
}
render() {
return super.render();
}
}
GridViewItem.ctor = GridViewItemClass;
export { GridViewItem };
@@ -0,0 +1,223 @@
import { Observer } from '@playcanvas/observer';
import { BindingObserversToElement } from '../../binding/BindingObserversToElement';
import { Container, ContainerArgs } from '../Container';
import { IFocusable } from '../Element';
import { Label } from '../Label';
import { RadioButton } from '../RadioButton';
const CLASS_ROOT = 'pcui-gridview-item';
const CLASS_ROOT_RADIO = 'pcui-gridview-radio-container';
const CLASS_SELECTED = `${CLASS_ROOT}-selected`;
const CLASS_TEXT = `${CLASS_ROOT}-text`;
const CLASS_RADIO_BUTTON = 'pcui-gridview-radiobtn';
/**
* The arguments for the {@link GridViewItem} constructor.
*/
interface GridViewItemArgs extends ContainerArgs {
/**
* The type of the {@link GridViewItem}. Can be `null` or 'radio'.
*/
type?: string;
/**
* If `true`, allow selecting the item. Defaults to `true`.
*/
allowSelect?: boolean;
/**
* Whether the item is selected.
*/
selected?: boolean;
/**
* The text of the item. Defaults to ''.
*/
text?: string;
/**
* Sets the tabIndex of the {@link GridViewItem}. Defaults to 0.
*/
tabIndex?: number;
}
/**
* Represents a grid view item used in {@link GridView}.
*/
class GridViewItem extends Container implements IFocusable {
/**
* Determines whether the item can be selected. Defaults to `true`.
*/
public allowSelect = true;
protected _selected: boolean;
protected _radioButton: RadioButton;
protected _labelText: Label;
protected _type: string;
/**
* Creates a new GridViewItem.
*
* @param args - The arguments.
*/
constructor(args: Readonly<GridViewItemArgs> = {}) {
super({ tabIndex: 0, ...args });
this.allowSelect = args.allowSelect ?? true;
this._type = args.type ?? null;
this._selected = false;
if (args.type === 'radio') {
this.class.add(CLASS_ROOT_RADIO);
this._radioButton = new RadioButton({
class: CLASS_RADIO_BUTTON,
binding: new BindingObserversToElement()
});
// @ts-ignore Remove radio button click event listener
this._radioButton.dom.removeEventListener('click', this._radioButton._onClick);
this._radioButton.dom.addEventListener('click', this._onRadioButtonClick);
this.append(this._radioButton);
} else {
this.class.add(CLASS_ROOT);
}
this._labelText = new Label({
class: CLASS_TEXT,
binding: new BindingObserversToElement(),
text: args.text ?? ''
});
this.append(this._labelText);
this.dom.addEventListener('focus', this._onFocus);
this.dom.addEventListener('blur', this._onBlur);
}
destroy() {
if (this._destroyed) return;
this.dom.removeEventListener('focus', this._onFocus);
this.dom.removeEventListener('blur', this._onBlur);
super.destroy();
}
protected _onRadioButtonClick = () => {
this._radioButton.value = this.selected;
};
protected _onFocus = () => {
this.emit('focus');
};
protected _onBlur = () => {
this.emit('blur');
};
focus() {
this.dom.focus();
}
blur() {
this.dom.blur();
}
link(observers: Observer|Observer[], paths: string|string[]) {
this._labelText.link(observers, paths);
}
unlink() {
this._labelText.unlink();
}
/**
* Sets whether the item is selected.
*/
set selected(value) {
if (value) {
this.focus();
}
if (this._selected === value) return;
this._selected = value;
if (value) {
// Update radio button if it exists
if (this._radioButton) {
this._radioButton.value = value;
} else {
this.class.add(CLASS_SELECTED);
}
this.emit('select', this);
} else {
// Update radio button if it exists
if (this._radioButton) {
this._radioButton.value = false;
} else {
this.class.remove(CLASS_SELECTED);
}
this.emit('deselect', this);
}
}
/**
* Gets whether the item is selected.
*/
get selected() {
return this._selected;
}
/**
* Sets the text of the item.
*/
set text(value) {
this._labelText.text = value;
}
/**
* Gets the text of the item.
*/
get text() {
return this._labelText.text;
}
/**
* Gets the next visible sibling grid view item.
*/
get nextSibling() {
let sibling = this.dom.nextSibling;
while (sibling) {
if (sibling.ui instanceof GridViewItem && !sibling.ui.hidden) {
return sibling.ui;
}
sibling = sibling.nextSibling;
}
return null;
}
/**
* Gets the previous visible sibling grid view item.
*/
get previousSibling() {
let sibling = this.dom.previousSibling;
while (sibling) {
if (sibling.ui instanceof GridViewItem && !sibling.ui.hidden) {
return sibling.ui;
}
sibling = sibling.previousSibling;
}
return null;
}
}
export { GridViewItem, GridViewItemArgs };
@@ -0,0 +1,47 @@
// Gridview Regular Item
.pcui-gridview-item {
@extend .pcui-flex;
box-sizing: border-box;
justify-content: center;
align-items: center;
flex-shrink: 0;
width: 104px;
&:not(.pcui-disabled) {
cursor: pointer;
&:not(.pcui-gridview-item-selected, .pcui-gridview-radiobtn, .pcui-gridview-radiobtn-selected):hover {
background-color: $bcg-darker;
}
}
}
.pcui-gridview-item-selected {
background-color: $bcg-darkest;
}
.pcui-gridview-item-text {
max-width: 100px;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
padding: 0 2px;
}
// GridView Radio Buttons
.pcui-gridview-radio-container {
@extend .pcui-flex;
box-sizing: border-box;
flex-direction: row;
justify-content: center;
align-items: center;
flex-shrink: 0;
width: 104px;
:not(.pcui-disabled) {
cursor: pointer;
}
}
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import { InfoBox } from './component';
import '../../scss/index.js';
const meta: Meta<typeof InfoBox> = {
title: 'Components/InfoBox',
component: InfoBox
};
export default meta;
type Story = StoryObj<typeof InfoBox>;
export const Main: Story = {
render: args => <InfoBox {...args} icon='E401' title='Foo' text='Bar' />
};
@@ -0,0 +1,24 @@
import * as React from 'react';
import { Element } from '../Element/component';
import { InfoBox as InfoBoxClass, InfoBoxArgs } from './index';
/**
* Represents an information box.
*/
class InfoBox extends Element<InfoBoxArgs, any> {
constructor(props: InfoBoxArgs) {
super(props);
this.elementClass = InfoBoxClass;
}
render() {
// @ts-ignore
return <span ref={this.attachElement} />;
}
}
InfoBox.ctor = InfoBoxClass;
export { InfoBox };
@@ -0,0 +1,133 @@
import { Container, ContainerArgs } from '../Container';
import { Element } from '../Element';
const CLASS_INFOBOX = 'pcui-infobox';
/**
* The arguments for the {@link InfoBox} constructor.
*/
interface InfoBoxArgs extends ContainerArgs {
/**
* The CSS code for an icon for the {@link InfoBox}. e.g. 'E401' (notice we omit the '\\' character). Defaults to ''.
* Useful icon values for InfoBox are:
*
* - 'E218' - warning
* - 'E400' - info
*/
icon?: string;
/**
* Sets the 'title' of the {@link InfoBox}. Defaults to ''.
*/
title?: string;
/**
* Sets the 'text' of the {@link InfoBox}. Defaults to ''.
*/
text?: string;
/**
* If `true`, the {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML innerHTML} property will be
* used to set the text. Otherwise, {@link https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent textContent}
* will be used instead. Defaults to `false`.
*/
unsafe?: boolean;
}
/**
* Represents an information box.
*/
class InfoBox extends Container {
protected _titleElement = new Element();
protected _textElement = new Element();
protected _unsafe: boolean;
protected _icon: string;
protected _title: string;
protected _text: string;
/**
* Creates a new InfoBox.
*
* @param args - The arguments.
*/
constructor(args: Readonly<InfoBoxArgs> = {}) {
super(args);
this.class.add(CLASS_INFOBOX);
this.append(this._titleElement);
this.append(this._textElement);
this._unsafe = args.unsafe ?? false;
this.icon = args.icon ?? '';
this.title = args.title ?? '';
this.text = args.text ?? '';
}
/**
* Sets the icon of the info box.
*/
set icon(value) {
if (this._icon === value) return;
this._icon = value;
if (value) {
// set data-icon attribute but first convert the value to a code point
this.dom.setAttribute('data-icon', String.fromCodePoint(parseInt(value, 16)));
} else {
this.dom.removeAttribute('data-icon');
}
}
/**
* Gets the icon of the info box.
*/
get icon() {
return this._icon;
}
/**
* Sets the title of the info box.
*/
set title(value) {
if (this._title === value) return;
this._title = value;
if (this._unsafe) {
this._titleElement.dom.innerHTML = value;
} else {
this._titleElement.dom.textContent = value;
}
}
/**
* Gets the title of the info box.
*/
get title() {
return this._title;
}
/**
* Sets the text of the info box.
*/
set text(value) {
if (this._text === value) return;
this._text = value;
if (this._unsafe) {
this._textElement.dom.innerHTML = value;
} else {
this._textElement.dom.textContent = value;
}
}
/**
* Gets the text of the info box.
*/
get text() {
return this._text;
}
}
Element.register('infobox', InfoBox);
export { InfoBox, InfoBoxArgs };
@@ -0,0 +1,35 @@
.pcui-infobox {
box-sizing: border-box;
margin: $element-margin;
padding: 2 * $element-margin;
border: 1px solid $bcg-darker;
border-radius: 2px;
background-color: $bcg-dark;
color: $text-secondary;
font-size: 12px;
:first-child {
color: $text-primary;
margin-bottom: 2px;
}
// show icon using data-icon attribute
// as the content
&[data-icon]:not(.pcui-hidden) {
display: grid;
grid: auto-flow / min-content 1fr;
&::before {
content: attr(data-icon);
@extend .font-icon;
font-weight: 100;
font-size: 16px;
margin-right: 2 * $element-margin;
vertical-align: middle;
grid-column: 1;
grid-row: 1 / 3;
}
}
}
@@ -0,0 +1,255 @@
import { CLASS_FOCUS } from '../../class';
import { Element, ElementArgs, IBindable, IBindableArgs, IFocusable, IPlaceholder, IPlaceholderArgs } from '../Element';
const CLASS_INPUT_ELEMENT = 'pcui-input-element';
/**
* The arguments for the {@link InputElement} constructor.
*/
interface InputElementArgs extends ElementArgs, IBindableArgs, IPlaceholderArgs {
/**
* Sets whether pressing Enter will blur (unfocus) the field. Defaults to `true`.
*/
blurOnEnter?: boolean,
/**
* Sets whether pressing Escape will blur (unfocus) the field. Defaults to `true`.
*/
blurOnEscape?: boolean,
/**
* Sets whether any key up event will cause a change event to be fired.
*/
keyChange?: boolean,
/**
* The input element to associate this {@link InputElement} with. If not supplied one will be created instead.
*/
input?: HTMLInputElement
}
/**
* The InputElement is an abstract class that manages an input DOM element. It is the superclass of
* {@link TextInput} and {@link NumericInput}. It is not intended to be used directly.
*/
abstract class InputElement extends Element implements IBindable, IFocusable, IPlaceholder {
/**
* Determines whether the input should blur when the enter key is pressed.
*/
public blurOnEnter = true;
/**
* Determines whether the input should blur when the escape key is pressed.
*/
public blurOnEscape = true;
protected _domInput: HTMLInputElement;
protected _suspendInputChangeEvt: boolean;
protected _prevValue: string;
protected _keyChange: boolean;
protected _renderChanges: boolean;
protected _onInputKeyDownEvt: (evt: KeyboardEvent) => void;
protected _onInputChangeEvt: (evt: Event) => void;
constructor(args: InputElementArgs = {}) {
super(args);
this.class.add(CLASS_INPUT_ELEMENT);
let input = args.input;
if (!input) {
input = document.createElement('input');
input.type = 'text';
}
input.ui = this;
input.tabIndex = 0;
input.autocomplete = 'off';
this._onInputKeyDownEvt = this._onInputKeyDown.bind(this);
this._onInputChangeEvt = this._onInputChange.bind(this);
input.addEventListener('change', this._onInputChangeEvt);
input.addEventListener('keydown', this._onInputKeyDownEvt);
input.addEventListener('focus', this._onInputFocus);
input.addEventListener('blur', this._onInputBlur);
input.addEventListener('contextmenu', this._onInputCtxMenu, false);
this.dom.appendChild(input);
this._domInput = input;
this._suspendInputChangeEvt = false;
if (args.value !== undefined) {
this._domInput.value = args.value;
}
this.placeholder = args.placeholder ?? '';
this.renderChanges = args.renderChanges ?? false;
this.blurOnEnter = args.blurOnEnter ?? true;
this.blurOnEscape = args.blurOnEscape ?? true;
this.keyChange = args.keyChange ?? false;
this._prevValue = null;
this.on('change', () => {
if (this.renderChanges) {
this.flash();
}
});
this.on('disable', this._updateInputReadOnly);
this.on('enable', this._updateInputReadOnly);
this.on('readOnly', this._updateInputReadOnly);
this._updateInputReadOnly();
}
destroy() {
if (this._destroyed) return;
const input = this._domInput;
input.removeEventListener('change', this._onInputChangeEvt);
input.removeEventListener('keydown', this._onInputKeyDownEvt);
input.removeEventListener('focus', this._onInputFocus);
input.removeEventListener('blur', this._onInputBlur);
input.removeEventListener('keyup', this._onInputKeyUp);
input.removeEventListener('contextmenu', this._onInputCtxMenu);
super.destroy();
}
protected _onInputFocus = (evt: FocusEvent) => {
this.class.add(CLASS_FOCUS);
this.emit('focus', evt);
this._prevValue = this._domInput.value;
};
protected _onInputBlur = (evt: FocusEvent) => {
this.class.remove(CLASS_FOCUS);
this.emit('blur', evt);
};
protected _onInputKeyDown(evt: KeyboardEvent) {
if (evt.key === 'Enter' && this.blurOnEnter) {
// do not fire input change event on blur
// if keyChange is true (because a change event)
// will have already been fired before for the current
// value
this._suspendInputChangeEvt = this.keyChange;
this._domInput.blur();
this._suspendInputChangeEvt = false;
} else if (evt.key === 'Escape') {
this._suspendInputChangeEvt = true;
const prev = this._domInput.value;
this._domInput.value = this._prevValue;
this._suspendInputChangeEvt = false;
// manually fire change event
if (this.keyChange && prev !== this._prevValue) {
this._onInputChange(evt);
}
if (this.blurOnEscape) {
this._domInput.blur();
}
}
this.emit('keydown', evt);
}
protected _onInputChange(evt: Event) {}
protected _onInputKeyUp = (evt: KeyboardEvent) => {
if (evt.key !== 'Escape') {
this._onInputChange(evt);
}
this.emit('keyup', evt);
};
protected _onInputCtxMenu = (evt: MouseEvent) => {
this._domInput.select();
};
protected _updateInputReadOnly = () => {
const readOnly = !this.enabled || this.readOnly;
if (readOnly) {
this._domInput.setAttribute('readonly', 'true');
} else {
this._domInput.removeAttribute('readonly');
}
};
focus(select?: boolean) {
this._domInput.focus();
if (select) {
this._domInput.select();
}
}
blur() {
this._domInput.blur();
}
set placeholder(value: string) {
if (value) {
this.dom.setAttribute('placeholder', value);
} else {
this.dom.removeAttribute('placeholder');
}
}
get placeholder(): string {
return this.dom.getAttribute('placeholder') ?? '';
}
/**
* Sets the method to call when keyup is called on the input DOM element.
*/
set keyChange(value) {
if (this._keyChange === value) return;
this._keyChange = value;
if (value) {
this._domInput.addEventListener('keyup', this._onInputKeyUp);
} else {
this._domInput.removeEventListener('keyup', this._onInputKeyUp);
}
}
/**
* Gets the method to call when keyup is called on the input DOM element.
*/
get keyChange() {
return this._keyChange;
}
/**
* Gets the input DOM element.
*/
get input() {
return this._domInput;
}
abstract set value(value: any);
abstract get value(): any;
abstract set values(value: Array<any>);
abstract get values(): Array<any>;
set renderChanges(value: boolean) {
this._renderChanges = value;
}
get renderChanges(): boolean {
return this._renderChanges;
}
}
export { InputElement, InputElementArgs };
@@ -0,0 +1,110 @@
.pcui-input-element {
display: inline-block;
border: 1px solid $bcg-darker;
border-radius: 2px;
box-sizing: border-box;
margin: $element-margin;
min-height: 24px;
height: 24px;
background-color: $bcg-dark;
vertical-align: top;
transition: color 100ms, background-color 100ms, box-shadow 100ms;
position: relative;
color: $text-secondary;
> input {
height: 100%;
width: calc(100% - 16px);
padding: 0 $element-margin;
line-height: 1;
color: inherit;
background: transparent;
border: none;
outline: none;
box-shadow: none;
@extend .fixed-font;
}
&::before {
color: inherit;
}
}
.pcui-input-element.pcui-multiple-values {
&::before {
position: absolute;
padding: 0 8px;
content: '...';
white-space: nowrap;
@extend .fixed-font;
top: 5px;
font-size: 12px;
}
}
// focus / hover states
.pcui-input-element:not(.pcui-disabled, .pcui-readonly) {
&:hover {
background-color: $bcg-darker;
color: $text-primary;
}
&:not(.pcui-error):hover {
box-shadow: $element-shadow-hover;
}
&.pcui-focus {
background-color: $bcg-darkest;
box-shadow: $element-shadow-active;
}
}
.pcui-input-element {
&.pcui-focus,
&:hover {
&::after,
&::before {
display: none;
}
}
}
// readonly state
.pcui-input-element.pcui-readonly {
background-color: rgba($bcg-dark, $element-opacity-readonly);
border-color: transparent;
}
// disabled state
.pcui-input-element.pcui-disabled {
color: $text-darkest;
}
// error state
.pcui-input-element.pcui-error {
color: $text-secondary;
box-shadow: $element-shadow-error;
}
// placeholder
.pcui-input-element[placeholder] {
position: relative;
&::after {
content: attr(placeholder);
background-color: $bcg-dark;
position: absolute;
top: 0;
right: 0;
padding: 0 8px;
line-height: 22px;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
color: $element-color-placeholder;
pointer-events: none;
}
}

Some files were not shown because too many files have changed in this diff Show More