├── .eslintignore ├── .gitignore ├── docs └── images │ └── pg.jpg ├── src ├── decorators │ ├── index.ts │ └── property-grid-control-decorator.ts ├── models │ ├── property-grid-constants.ts │ ├── property-grid-config.ts │ ├── property-grid-group.ts │ ├── index.ts │ ├── property-grid-item.ts │ ├── property-grid-config-item.ts │ ├── property-grid-options.ts │ └── types.ts ├── controls │ ├── form-controls-map.ts │ ├── property-grid-form-controls-map.ts │ ├── textbox-form-control.ts │ ├── color-picker-form-control.ts │ ├── date-picker-form-control.ts │ ├── number-form-control.ts │ ├── checkbox-form-control.ts │ ├── index.ts │ ├── label-form-control.ts │ ├── form-control-composite.ts │ ├── input-form-control.ts │ ├── select-form-control.ts │ ├── property-grid-row.ts │ ├── iform-control.ts │ ├── property-grid-row-group.ts │ ├── form-control.ts │ └── property-grid-form.ts ├── property-grid-template.ts ├── services │ ├── index.ts │ ├── logger.service.ts │ ├── property-grid-service.ts │ ├── event-dispatcher-service.ts │ ├── property-grid-factory.ts │ ├── config-parser-service.ts │ └── property-grid-groups-builder-service.ts ├── index.ts ├── index.html ├── utils │ └── property-grid-utils.ts ├── styles │ └── property-grid-base-style.ts ├── sample │ └── index.js └── property-grid.component.ts ├── .npmignore ├── .prettierrc ├── .editorconfig ├── tsconfig.json ├── LICENSE ├── .eslintrc ├── package.json ├── webpack.config.js ├── webpack.prod.config.js └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .DS_Store 3 | node_modules 4 | 5 | dist 6 | lib 7 | lib-esm 8 | _bundles 9 | -------------------------------------------------------------------------------- /docs/images/pg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bdimitrijoski/clean-web-ui-property-grid/HEAD/docs/images/pg.jpg -------------------------------------------------------------------------------- /src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export { propertyGridControlDecorator } from './property-grid-control-decorator'; 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | **/tsconfig.json 3 | **/webpack.config.js 4 | 5 | node_modules 6 | src 7 | docs 8 | .vscode 9 | -------------------------------------------------------------------------------- /src/models/property-grid-constants.ts: -------------------------------------------------------------------------------- 1 | export const PROPERTY_GRID_CONSTANTS = { 2 | OTHER_GROUP_NAME: 'Other', 3 | }; 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 140 6 | } 7 | -------------------------------------------------------------------------------- /src/controls/form-controls-map.ts: -------------------------------------------------------------------------------- 1 | import { IFormControl } from './iform-control'; 2 | 3 | export interface FormControlsMap { 4 | [controlName: string]: IFormControl; 5 | } 6 | -------------------------------------------------------------------------------- /src/models/property-grid-config.ts: -------------------------------------------------------------------------------- 1 | import { PropertyGridConfigItem } from './property-grid-config-item'; 2 | 3 | export interface PropertyGridConfig { 4 | [name: string]: PropertyGridConfigItem; 5 | } 6 | -------------------------------------------------------------------------------- /src/models/property-grid-group.ts: -------------------------------------------------------------------------------- 1 | import { PropertyGridItem } from './property-grid-item'; 2 | 3 | export interface PropertyGridGroup { 4 | name: string; 5 | label: string; 6 | children: PropertyGridItem[]; 7 | } 8 | -------------------------------------------------------------------------------- /src/controls/property-grid-form-controls-map.ts: -------------------------------------------------------------------------------- 1 | import { PropertyGridRowGroup } from './property-grid-row-group'; 2 | 3 | export interface PropertyGridFormControlsMap { 4 | [rowGroupName: string]: PropertyGridRowGroup; 5 | } 6 | -------------------------------------------------------------------------------- /src/controls/textbox-form-control.ts: -------------------------------------------------------------------------------- 1 | import { InputFormControl } from './input-form-control'; 2 | 3 | export class TextboxFormControl extends InputFormControl { 4 | getInputType(): string { 5 | return 'text'; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/controls/color-picker-form-control.ts: -------------------------------------------------------------------------------- 1 | import { InputFormControl } from './input-form-control'; 2 | 3 | export class ColorPickerFormControl extends InputFormControl { 4 | getInputType(): string { 5 | return 'color'; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/property-grid-template.ts: -------------------------------------------------------------------------------- 1 | const propertyGridTemplate = document.createElement('template'); 2 | const propertyGridTemplateContent = ` 3 |
4 | `; 5 | propertyGridTemplate.innerHTML = propertyGridTemplateContent; 6 | 7 | export { propertyGridTemplate }; 8 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export { PropertyGridGroup } from './property-grid-group'; 2 | export { PropertyGridItem } from './property-grid-item'; 3 | export { PropertyGridConfig } from './property-grid-config'; 4 | export { PropertyGridConfigItem } from './property-grid-config-item'; 5 | export { PropertyGridOptions } from './property-grid-options'; 6 | -------------------------------------------------------------------------------- /src/models/property-grid-item.ts: -------------------------------------------------------------------------------- 1 | import { Attributes } from './types'; 2 | 3 | export interface PropertyGridItem { 4 | id?: string; 5 | name: string; 6 | label?: string; 7 | type: string; 8 | description?: string; 9 | attributes: Attributes; 10 | options: { 11 | description?: string; 12 | showHelp?: boolean; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | 15 | [*.json] 16 | max_line_length = 20 17 | -------------------------------------------------------------------------------- /src/controls/date-picker-form-control.ts: -------------------------------------------------------------------------------- 1 | import { InputFormControl } from './input-form-control'; 2 | 3 | export class DatePickerFormControl extends InputFormControl { 4 | getValue(): string { 5 | return super.getValue(); 6 | } 7 | 8 | setValue(value: string | Date): void { 9 | super.setValue(value); 10 | } 11 | 12 | getInputType(): string { 13 | return 'date'; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/controls/number-form-control.ts: -------------------------------------------------------------------------------- 1 | import { InputFormControl } from './input-form-control'; 2 | 3 | export class NumberFormControl extends InputFormControl { 4 | getValue(): number { 5 | return +super.getValue(); 6 | } 7 | 8 | setValue(value: string | number): void { 9 | super.setValue(+value.toString()); 10 | } 11 | 12 | getInputType(): string { 13 | return 'number'; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export { ConfigParserService } from './config-parser-service'; 2 | export { EventDispatcherService } from './event-dispatcher-service'; 3 | export { Logger } from './logger.service'; 4 | export { PropertyGridFactory } from './property-grid-factory'; 5 | export { PropertyGridGroupsBuilderService } from './property-grid-groups-builder-service'; 6 | export { PropertyGridService } from './property-grid-service'; 7 | -------------------------------------------------------------------------------- /src/models/property-grid-config-item.ts: -------------------------------------------------------------------------------- 1 | export interface PropertyGridConfigItem { 2 | id?: string; 3 | name: string; 4 | label: string; 5 | /** Type of control (text, checkbox...etc.) */ 6 | type: string; 7 | /** Using for showing help */ 8 | description?: string; 9 | /** The group where this control belongs */ 10 | group?: string; 11 | /** Should be visible in the property grid */ 12 | browsable?: boolean; 13 | /** Should show help for this item */ 14 | showHelp?: boolean; 15 | items?: string[]; 16 | options: { 17 | [option: string]: string | number | boolean; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "./src", 4 | "baseUrl": "./src", 5 | "moduleResolution": "node", 6 | "module": "commonjs", 7 | "target": "es2015", 8 | "lib": ["es2017", "dom"], 9 | "outDir": "dist/lib", 10 | "allowSyntheticDefaultImports": true, 11 | "suppressImplicitAnyIndexErrors": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "sourceMap": true, 14 | "emitDecoratorMetadata": true, 15 | "experimentalDecorators": true, 16 | "allowJs": false, 17 | "declaration": true 18 | }, 19 | "include": ["**/*.ts"], 20 | "exclude": ["node_modules", "dist", "**/*.spec.ts", "sample"], 21 | "compileOnSave": false, 22 | "buildOnSave": false 23 | } 24 | -------------------------------------------------------------------------------- /src/controls/checkbox-form-control.ts: -------------------------------------------------------------------------------- 1 | import { FormEvent } from '../models/types'; 2 | 3 | import { PropertyGridUtils } from '../utils/property-grid-utils'; 4 | 5 | import { InputFormControl } from './input-form-control'; 6 | 7 | export class CheckboxFormControl extends InputFormControl { 8 | getInputType(): string { 9 | return 'checkbox'; 10 | } 11 | 12 | getValue(): boolean { 13 | return this.value as boolean; 14 | } 15 | 16 | setValue(value: string | boolean): void { 17 | const val = PropertyGridUtils.toBool(value); 18 | super.setValue(val); 19 | this.getNativeElement().checked = val; 20 | } 21 | 22 | protected onValueChange(event: FormEvent): void { 23 | this.setValue(event.target.checked); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { PropertyGrid } from './property-grid.component'; 2 | export { 3 | IFormControl, 4 | FormControlsMap, 5 | FormControl, 6 | FormControlComposite, 7 | InputFormControl, 8 | PropertyGridForm, 9 | PropertyGridRow, 10 | PropertyGridRowGroup, 11 | } from './controls'; 12 | 13 | export { PropertyGridConfig, PropertyGridConfigItem, PropertyGridGroup, PropertyGridItem, PropertyGridOptions } from './models'; 14 | 15 | export { Attributes, DataObject, EventHandler, EventListnerHandle, FormEvent, FormEventHandler, PropertyGridEvents } from './models/types'; 16 | 17 | export { 18 | ConfigParserService, 19 | EventDispatcherService, 20 | PropertyGridFactory, 21 | PropertyGridGroupsBuilderService, 22 | PropertyGridService, 23 | } from './services'; 24 | -------------------------------------------------------------------------------- /src/services/logger.service.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 3 | export class Logger { 4 | private static instance: Logger; 5 | logEnabled = true; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-empty-function 8 | private constructor() {} 9 | 10 | public static getInstance(): Logger { 11 | if (!Logger.instance) { 12 | Logger.instance = new Logger(); 13 | } 14 | 15 | return Logger.instance; 16 | } 17 | 18 | error(msg: string, data?: any): void { 19 | // eslint-disable-next-line no-console 20 | console.error(msg, data); 21 | } 22 | 23 | log(msg: string, data?: any): void { 24 | // eslint-disable-next-line no-console 25 | if (!this.logEnabled) { 26 | return; 27 | } 28 | // eslint-disable-next-line no-console 29 | console.log(msg, data); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/controls/index.ts: -------------------------------------------------------------------------------- 1 | export { CheckboxFormControl } from './checkbox-form-control'; 2 | export { ColorPickerFormControl } from './color-picker-form-control'; 3 | export { DatePickerFormControl } from './date-picker-form-control'; 4 | export { FormControl } from './form-control'; 5 | export { FormControlComposite } from './form-control-composite'; 6 | export { FormControlsMap } from './form-controls-map'; 7 | export { IFormControl } from './iform-control'; 8 | export { InputFormControl } from './input-form-control'; 9 | export { NumberFormControl } from './number-form-control'; 10 | export { PropertyGridForm } from './property-grid-form'; 11 | export { PropertyGridRow } from './property-grid-row'; 12 | export { PropertyGridRowGroup } from './property-grid-row-group'; 13 | export { SelectFormControl } from './select-form-control'; 14 | export { TextboxFormControl } from './textbox-form-control'; 15 | export { LabelFormControl } from './label-form-control'; 16 | -------------------------------------------------------------------------------- /src/models/property-grid-options.ts: -------------------------------------------------------------------------------- 1 | import { FormControlsMap } from '../controls/form-controls-map'; 2 | 3 | import { FormEvent } from './types'; 4 | 5 | export interface PropertyGridOptions { 6 | /** 7 | * indicating whether the control can receive focus. 8 | */ 9 | canFocus?: boolean; 10 | 11 | /** Set custom controls that will be used for rendering */ 12 | controls?: FormControlsMap; 13 | 14 | /** Gets or sets a value indicating whether the Help text is visible. */ 15 | helpVisible?: boolean; 16 | 17 | /** Gets or sets if PropertyGrid will sort properties by display name. */ 18 | propertySort?: boolean | ((params: string[]) => string[]); 19 | 20 | /** Gets or sets a value indicating whether the toolbar is visible. */ 21 | toolbarVisible?: boolean; 22 | 23 | /** Gets or sets a value indicating whether controls are groupped and if groups are visible. */ 24 | hasGroups?: boolean; 25 | 26 | /** Hook that is called when value is change in controls inside property grid */ 27 | onValueChange?: (event: FormEvent) => any; 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 brankodimitrijoski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/controls/label-form-control.ts: -------------------------------------------------------------------------------- 1 | import { FormEvent } from '../models/types'; 2 | 3 | import { FormControl, FormControlParams } from './form-control'; 4 | 5 | export class LabelFormControl extends FormControl { 6 | constructor(params: FormControlParams) { 7 | super(params); 8 | this.onValueChange = this.onValueChange.bind(this); 9 | } 10 | 11 | getNativeElementType(): string { 12 | return 'label'; 13 | } 14 | 15 | getNativeElement(): HTMLLabelElement { 16 | return super.getNativeElement() as HTMLLabelElement; 17 | } 18 | 19 | getValue(): boolean { 20 | return this.value as boolean; 21 | } 22 | 23 | setValue(value: string): void { 24 | super.setValue(value); 25 | this.getNativeElement().innerHTML = value; 26 | } 27 | 28 | render(): HTMLLabelElement { 29 | this.getNativeElement().innerHTML = this.value; 30 | return this.getNativeElement(); 31 | } 32 | 33 | protected onValueChange(event: FormEvent): void { 34 | this.setValue(event.target.innerText); 35 | } 36 | 37 | // eslint-disable-next-line @typescript-eslint/no-empty-function 38 | attachEventListeners(): void {} 39 | // eslint-disable-next-line @typescript-eslint/no-empty-function 40 | removeEventListeners(): void {} 41 | } 42 | -------------------------------------------------------------------------------- /src/models/types.ts: -------------------------------------------------------------------------------- 1 | export interface Attributes { 2 | [attributeName: string]: string | number | boolean; 3 | } 4 | 5 | export interface Options { 6 | [attributeName: string]: string | number | boolean | string[]; 7 | } 8 | 9 | export interface SortCallback { 10 | (properties: string[]): string[]; 11 | } 12 | 13 | export type PrimitiveValue = string | number | boolean; 14 | 15 | export interface DataObject { 16 | [property: string]: string | number | Date | boolean | any; 17 | } 18 | 19 | export interface FormEvent extends Event { 20 | bubbles: boolean; 21 | currentTarget: EventTarget & T; 22 | cancelable: boolean; 23 | defaultPrevented: boolean; 24 | eventPhase: number; 25 | isTrusted: boolean; 26 | nativeEvent: Event; 27 | preventDefault(): void; 28 | isDefaultPrevented(): boolean; 29 | stopPropagation(): void; 30 | isPropagationStopped(): boolean; 31 | persist(): void; 32 | target: EventTarget & T; 33 | type: string; 34 | } 35 | 36 | export interface EventHandler> { 37 | (event: E): void; 38 | } 39 | 40 | export type FormEventHandler = EventHandler>; 41 | 42 | export interface EventListnerHandle { 43 | unsubscribe: () => any; 44 | } 45 | 46 | export interface OnValueChangeEvent { 47 | name: string; 48 | value: any; 49 | } 50 | 51 | export enum PropertyGridEvents { 52 | onValueChanged = 'onValueChanged', 53 | } 54 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "prettier", "no-loops", "import"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "prettier" 9 | ], 10 | "rules": { 11 | "no-console": 1, 12 | "prettier/prettier": 2, 13 | "sort-imports": [ 14 | "error", 15 | { 16 | "ignoreDeclarationSort": true 17 | } 18 | ], 19 | "import/order": [ 20 | "error", 21 | { 22 | "pathGroupsExcludedImportTypes": ["external"], 23 | "groups": [ 24 | "builtin", 25 | "external", 26 | "internal", 27 | "parent", 28 | "sibling", 29 | "index" 30 | ], 31 | "alphabetize": { 32 | "order": "asc" 33 | }, 34 | "newlines-between": "always-and-inside-groups" 35 | } 36 | ], 37 | "no-prototype-builtins": "off", 38 | "@typescript-eslint/no-explicit-any": "off" 39 | }, 40 | "settings": { 41 | "import/parsers": { 42 | "@typescript-eslint/parser": [".ts"] 43 | }, 44 | "import/resolver": { 45 | "typescript": { 46 | "alwaysTryTypes": true, // always try to resolve types under `@types` directory even it doesn't contain any source code, like `@types/unist` 47 | "directory": "./" 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/decorators/property-grid-control-decorator.ts: -------------------------------------------------------------------------------- 1 | import { IFormControl } from '../controls'; 2 | import { PropertyGridOptions } from '../models'; 3 | import { EventDispatcherService } from '../services'; 4 | 5 | export const propertyGridControlDecorator = ( 6 | ctrl: IFormControl, 7 | pgOptions: PropertyGridOptions, 8 | eventDispatcher: EventDispatcherService, 9 | ): IFormControl => { 10 | const proxyMethod = (method, callback) => { 11 | return new Proxy(method, { 12 | apply: (target, that, args) => { 13 | callback(target, that, args); 14 | return target.apply(that, args); 15 | }, 16 | }); 17 | }; 18 | 19 | if (typeof ctrl.onValueChange === 'function') { 20 | ctrl.onValueChange = proxyMethod(ctrl.onValueChange, (target, that, args) => { 21 | if (pgOptions.onValueChange) { 22 | const event = args[0]; 23 | const result = pgOptions.onValueChange(event); 24 | if (result === false || event.defaultPrevented) { 25 | return false; 26 | } 27 | } 28 | }); 29 | } 30 | 31 | if (typeof ctrl.setValue === 'function') { 32 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 33 | ctrl.setValue = proxyMethod(ctrl.setValue, (target, that, args) => { 34 | const data = { 35 | name: that.getName(), 36 | value: that.getValue(), 37 | }; 38 | eventDispatcher.send('onValueChanged', data); 39 | }); 40 | } 41 | 42 | return ctrl; 43 | }; 44 | -------------------------------------------------------------------------------- /src/services/property-grid-service.ts: -------------------------------------------------------------------------------- 1 | import { PropertyGridForm, PropertyGridRow, PropertyGridRowGroup } from '../controls'; 2 | import { PropertyGridGroup, PropertyGridItem, PropertyGridOptions } from '../models'; 3 | 4 | import { PropertyGridFactory } from './property-grid-factory'; 5 | 6 | export class PropertyGridService { 7 | constructor(private factory: PropertyGridFactory) {} 8 | 9 | build(config: PropertyGridGroup[], pgOptions: PropertyGridOptions): PropertyGridForm { 10 | const propGrid = new PropertyGridForm({ 11 | id: 'pg', 12 | name: 'pg', 13 | label: '', 14 | description: '', 15 | attributes: {}, 16 | options: {}, 17 | }); 18 | propGrid.init(); 19 | 20 | config.forEach((group) => { 21 | propGrid.add(this.buildGridRowGroup(group, pgOptions)); 22 | }); 23 | 24 | return propGrid; 25 | } 26 | 27 | private buildGridRowGroup(item: PropertyGridGroup, pgOptions: PropertyGridOptions): PropertyGridRowGroup { 28 | const group = this.factory.create('row_group', item, pgOptions) as PropertyGridRowGroup; 29 | 30 | item.children.forEach((gridItem) => group.add(this.buildGridRow(gridItem, pgOptions))); 31 | 32 | return group; 33 | } 34 | 35 | private buildGridRow(gridItem: PropertyGridItem, pgOptions: PropertyGridOptions): PropertyGridRow { 36 | const row = this.factory.create('row', gridItem, pgOptions) as PropertyGridRow; 37 | row.add(this.factory.create(gridItem.type, gridItem, pgOptions)); 38 | return row; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/services/event-dispatcher-service.ts: -------------------------------------------------------------------------------- 1 | import { EventListnerHandle } from '../models/types'; 2 | 3 | export class EventDispatcherService { 4 | private subscribers = {}; 5 | 6 | hasEventListener(event: string): boolean { 7 | return this.subscribers.hasOwnProperty(event) && this.subscribers[event].length > 0; 8 | } 9 | 10 | /** 11 | * Removes all subscribers 12 | */ 13 | reset(): void { 14 | for (const eventName in this.subscribers) { 15 | //clear array and event listeners 16 | this.subscribers[eventName].length = 0; 17 | this.subscribers[eventName] = null; 18 | delete this.subscribers[eventName]; 19 | } 20 | this.subscribers = {}; 21 | } 22 | 23 | /** Register (subscribe) to event */ 24 | register(event: string, callback: (...args) => any): EventListnerHandle { 25 | if (!this.subscribers.hasOwnProperty(event)) { 26 | this.subscribers[event] = []; 27 | } 28 | const index = this.subscribers[event].push(callback) - 1; 29 | const subscription = { 30 | unsubscribe: () => { 31 | this.subscribers[event].splice(index, 1); 32 | 33 | if (this.subscribers[event].length === 0) { 34 | delete this.subscribers[event]; 35 | } 36 | }, 37 | }; 38 | return subscription; 39 | } 40 | 41 | /** Broadcast event to all registered subscribers */ 42 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 43 | send(event: string, data?: any): void { 44 | if (!this.subscribers[event]) return; 45 | 46 | this.subscribers[event].forEach((callback) => callback(data)); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Clean Web UI Property Grid 8 | 21 | 22 | 23 | 24 |
25 |

Property Grid #1

26 | 27 |
28 |
29 | 30 |
31 | 32 |
33 |

Property Grid #2

34 |
35 | 36 | 37 |
38 | 39 |

Options

40 |
41 | 44 |
45 | 46 |
47 |

Values:

48 | 49 |
50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clean-web-ui-property-grid", 3 | "version": "1.1.1", 4 | "description": ".NET style property grid, written in Plain JavaScript as Web component", 5 | "main": "esm2015/index.js", 6 | "types": "esm2015/index.d.ts", 7 | "repository": "https://github.com/brankodimitrijoski/clean-web-ui-property-grid", 8 | "author": "Branko Dimitrijoski", 9 | "license": "MIT", 10 | "scripts": { 11 | "start": "webpack-dev-server", 12 | "clean": "shx rm -rf dist", 13 | "build": "npm run clean && tsc && tsc -m es2015 --target es2015 --outDir dist/esm2015 && webpack --config ./webpack.prod.config.js --mode=production", 14 | "lint": "eslint . --ext .ts", 15 | "lint-and-fix": "eslint . --ext .ts --fix", 16 | "prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write" 17 | }, 18 | "dependencies": { 19 | "shx": "^0.2.2", 20 | "typescript": "^3.8.3" 21 | }, 22 | "devDependencies": { 23 | "@typescript-eslint/eslint-plugin": "^3.7.1", 24 | "@typescript-eslint/eslint-plugin-tslint": "^3.7.1", 25 | "@typescript-eslint/parser": "^3.7.1", 26 | "awesome-typescript-loader": "^5.0.0-1", 27 | "copy-webpack-plugin": "^6.0.3", 28 | "eslint": "^7.5.0", 29 | "eslint-config-prettier": "^6.11.0", 30 | "eslint-import-resolver-typescript": "^2.2.0", 31 | "eslint-plugin-import": "^2.22.0", 32 | "eslint-plugin-no-loops": "^0.3.0", 33 | "eslint-plugin-prettier": "^3.1.4", 34 | "prettier": "^2.0.5", 35 | "terser-webpack-plugin": "^4.1.0", 36 | "uglifyjs-webpack-plugin": "^1.1.2", 37 | "webpack": "^4.43.0", 38 | "webpack-cli": "^3.3.11", 39 | "webpack-dev-server": "^3.1.11" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/controls/form-control-composite.ts: -------------------------------------------------------------------------------- 1 | import { DataObject } from '../models/types'; 2 | 3 | import { FormControl } from './form-control'; 4 | import { FormControlsMap } from './form-controls-map'; 5 | import { IFormControl } from './iform-control'; 6 | 7 | export abstract class FormControlComposite extends FormControl { 8 | protected controls: FormControlsMap = {}; 9 | 10 | add(field: IFormControl): void { 11 | const name = field.getName(); 12 | this.controls[name] = field; 13 | } 14 | 15 | remove(component: IFormControl): void { 16 | delete this.controls[component.getName()]; 17 | } 18 | 19 | setValue(data: DataObject): void { 20 | const setDataCallback = (control: IFormControl) => { 21 | const val = data.hasOwnProperty(control.getName()) ? data[control.getName()] : data; 22 | control.setValue(val); 23 | }; 24 | this.iterateControls(setDataCallback); 25 | } 26 | 27 | getData(): any { 28 | const data = {}; 29 | const getDataCallback = (control: IFormControl) => { 30 | data[control.getName()] = control.getValue(); 31 | }; 32 | this.iterateControls(getDataCallback); 33 | 34 | return data; 35 | } 36 | 37 | render(): any { 38 | const output = document.createDocumentFragment(); 39 | const renderCallback = (control: IFormControl) => output.appendChild(control.render()); 40 | this.iterateControls(renderCallback); 41 | return output; 42 | } 43 | 44 | destroy(): void { 45 | const destroyCallback = (control: IFormControl) => control.destroy(); 46 | this.iterateControls(destroyCallback); 47 | 48 | this.controls = {}; 49 | super.destroy(); 50 | } 51 | 52 | private iterateControls(callback: (control: IFormControl) => void): void { 53 | Object.keys(this.controls).forEach((controlName) => { 54 | callback(this.controls[controlName]); 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/property-grid-utils.ts: -------------------------------------------------------------------------------- 1 | import { DataObject } from '../models/types'; 2 | 3 | export class PropertyGridUtils { 4 | static toBool(value: string | boolean): boolean { 5 | return value.toString() === 'true'; 6 | } 7 | 8 | static camelize(str: string): string { 9 | return str 10 | .replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) { 11 | return index === 0 ? word.toLowerCase() : word.toUpperCase(); 12 | }) 13 | .replace(/\s+/g, ''); 14 | } 15 | 16 | static isColor(str: string): boolean { 17 | return str.toString().match(/^#[a-f0-9]{6}$/i) !== null; 18 | } 19 | 20 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 21 | static getDefaultVal(item: DataObject, property: string, defaultValue: any): any { 22 | return item.hasOwnProperty(property) ? item[property] : defaultValue; 23 | } 24 | 25 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 26 | static deepCopy(object: any): any { 27 | return JSON.parse(JSON.stringify(object)); 28 | } 29 | 30 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 31 | static valueToControlType(value: any): string { 32 | let valueType = typeof value as string; 33 | 34 | if (valueType === 'undefined') { 35 | return; 36 | } 37 | 38 | if (PropertyGridUtils.isColor(value)) { 39 | valueType = 'color'; 40 | } 41 | let controlType; 42 | switch (valueType) { 43 | case 'bigint': 44 | case 'number': 45 | controlType = 'number'; 46 | break; 47 | case 'string': 48 | controlType = 'text'; 49 | break; 50 | case 'boolean': 51 | controlType = 'boolean'; 52 | break; 53 | case 'color': 54 | controlType = 'color'; 55 | break; 56 | default: 57 | controlType = 'label'; 58 | } 59 | 60 | return controlType; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/services/property-grid-factory.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CheckboxFormControl, 3 | ColorPickerFormControl, 4 | DatePickerFormControl, 5 | FormControlsMap, 6 | IFormControl, 7 | LabelFormControl, 8 | NumberFormControl, 9 | PropertyGridForm, 10 | PropertyGridRow, 11 | PropertyGridRowGroup, 12 | SelectFormControl, 13 | TextboxFormControl, 14 | } from '../controls'; 15 | import { propertyGridControlDecorator } from '../decorators'; 16 | import { PropertyGridOptions } from '../models'; 17 | import { PropertyGridGroup } from '../models/property-grid-group'; 18 | import { PropertyGridItem } from '../models/property-grid-item'; 19 | 20 | import { EventDispatcherService } from './event-dispatcher-service'; 21 | 22 | export const COMPONENTS_MAP = { 23 | text: TextboxFormControl, 24 | number: NumberFormControl, 25 | boolean: CheckboxFormControl, 26 | row: PropertyGridRow, 27 | row_group: PropertyGridRowGroup, 28 | form: PropertyGridForm, 29 | options: SelectFormControl, 30 | color: ColorPickerFormControl, 31 | date: DatePickerFormControl, 32 | label: LabelFormControl, 33 | }; 34 | 35 | export class PropertyGridFactory { 36 | private customControls: FormControlsMap; 37 | constructor(private eventDispatcher: EventDispatcherService) {} 38 | 39 | registerControls(ctrls: FormControlsMap): void { 40 | this.customControls = {}; 41 | this.customControls = ctrls; 42 | } 43 | 44 | create(type: string, data: PropertyGridGroup | PropertyGridItem, pgOptions: PropertyGridOptions): IFormControl { 45 | const gridItem = data as PropertyGridItem; 46 | const controls: any = Object.assign(COMPONENTS_MAP, this.customControls); 47 | const ctrl = propertyGridControlDecorator( 48 | new controls[type]({ 49 | id: gridItem.id, 50 | name: gridItem.name, 51 | label: gridItem.label, 52 | description: gridItem.description, 53 | options: gridItem.options, 54 | }), 55 | pgOptions, 56 | this.eventDispatcher, 57 | ); 58 | 59 | ctrl.init(); 60 | return ctrl; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/controls/input-form-control.ts: -------------------------------------------------------------------------------- 1 | import { FormEvent } from '../models/types'; 2 | 3 | import { FormControl, FormControlParams } from './form-control'; 4 | 5 | export abstract class InputFormControl extends FormControl { 6 | constructor(params: FormControlParams) { 7 | super(params); 8 | this.onValueChange = this.onValueChange.bind(this); 9 | } 10 | 11 | /** 12 | * Set input type (checkbox, text, number) 13 | * @param type 14 | */ 15 | abstract getInputType(): string; 16 | 17 | getNativeElementType(): string { 18 | return 'input'; 19 | } 20 | 21 | getNativeElement(): HTMLInputElement { 22 | return super.getNativeElement() as HTMLInputElement; 23 | } 24 | 25 | renderValue(): void { 26 | if (!this.value) { 27 | return; 28 | } 29 | this.getNativeElement().value = this.value.toString(); 30 | } 31 | 32 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 33 | setValue(value: any): void { 34 | super.setValue(value); 35 | this.renderValue(); 36 | } 37 | 38 | render(): HTMLInputElement { 39 | this.assignAttributes(); 40 | this.renderValue(); 41 | return this.getNativeElement(); 42 | } 43 | 44 | createNatveElement(): void { 45 | super.createNatveElement(); 46 | this.getNativeElement().type = this.getInputType(); 47 | this.getNativeElement().name = this.getName(); 48 | } 49 | 50 | private assignAttributes() { 51 | if (!this.attributes) { 52 | return; 53 | } 54 | 55 | Object.keys(this.attributes).forEach((attr) => { 56 | this.getNativeElement().setAttribute(attr, this.attributes[attr].toString()); 57 | }); 58 | } 59 | 60 | attachEventListeners(): void { 61 | this.getNativeElement().addEventListener('change', this.onValueChange); 62 | } 63 | 64 | removeEventListeners(): void { 65 | this.getNativeElement().removeEventListener('change', this.onValueChange); 66 | } 67 | 68 | protected onValueChange(event: FormEvent): void { 69 | this.setValue(event.target.value); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/controls/select-form-control.ts: -------------------------------------------------------------------------------- 1 | import { FormEvent, Options } from '../models/types'; 2 | 3 | import { FormControl, FormControlParams } from './form-control'; 4 | 5 | export interface SelectFormControlOptions extends Options { 6 | items: string[]; 7 | } 8 | export interface SelectFormControlParams extends FormControlParams { 9 | options: SelectFormControlOptions; 10 | } 11 | 12 | export class SelectFormControl extends FormControl { 13 | private items: string[]; 14 | 15 | constructor(params: SelectFormControlParams) { 16 | super(params); 17 | this.items = params.options.items; 18 | 19 | this.onValueChange = this.onValueChange.bind(this); 20 | } 21 | 22 | getNativeElementType(): string { 23 | return 'select'; 24 | } 25 | 26 | getNativeElement(): HTMLSelectElement { 27 | return super.getNativeElement() as HTMLSelectElement; 28 | } 29 | 30 | setValue(value: string | number): void { 31 | super.setValue(value); 32 | this.renderValue(); 33 | } 34 | 35 | renderValue(): void { 36 | if (!this.value) { 37 | return; 38 | } 39 | this.getNativeElement().value = this.value.toString(); 40 | } 41 | 42 | createNatveElement(): void { 43 | super.createNatveElement(); 44 | this.getNativeElement().name = this.name; 45 | } 46 | 47 | render(): HTMLSelectElement { 48 | this.createOptions(); 49 | 50 | return this.getNativeElement(); 51 | } 52 | 53 | createOptions(): void { 54 | if (!this.options) { 55 | return; 56 | } 57 | 58 | this.items.forEach((item) => { 59 | const option = document.createElement('option') as HTMLOptionElement; 60 | option.innerHTML = item; 61 | option.value = item; 62 | this.getNativeElement().options.add(option); 63 | }); 64 | } 65 | 66 | attachEventListeners(): void { 67 | this.nativeElement.addEventListener('change', this.onValueChange); 68 | } 69 | removeEventListeners(): void { 70 | this.nativeElement.removeEventListener('change', this.onValueChange); 71 | } 72 | 73 | protected onValueChange(event: FormEvent): void { 74 | this.setValue(event.target.value); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/services/config-parser-service.ts: -------------------------------------------------------------------------------- 1 | import { PropertyGridConfig, PropertyGridConfigItem } from '../models'; 2 | import { PROPERTY_GRID_CONSTANTS } from '../models/property-grid-constants'; 3 | import { DataObject } from '../models/types'; 4 | import { PropertyGridUtils } from '../utils/property-grid-utils'; 5 | 6 | export class ConfigParserService { 7 | parse(selectedObject: DataObject, config?: PropertyGridConfig): PropertyGridConfig { 8 | config = config || {}; 9 | return this.createConfigItem(selectedObject, config); 10 | } 11 | 12 | /** Assigns property grid config items to same group */ 13 | assignToGroup(config: PropertyGridConfig, groupName = PROPERTY_GRID_CONSTANTS.OTHER_GROUP_NAME): void { 14 | Object.keys(config).forEach((key) => { 15 | config[key].group = groupName; 16 | }); 17 | } 18 | 19 | private createConfigItem(selectedObject: DataObject, configObject: PropertyGridConfig): PropertyGridConfig { 20 | const pgConfig = Object.keys(configObject).length ? configObject : selectedObject; 21 | let ID_COUNTER = 1; 22 | const config = {}; 23 | Object.keys(pgConfig).forEach((propertyName) => { 24 | const item = pgConfig[propertyName]; 25 | config[propertyName] = Object.assign(item, { 26 | id: `pg${PropertyGridUtils.camelize(propertyName)}${ID_COUNTER}`, 27 | name: PropertyGridUtils.camelize(propertyName), 28 | type: this.defaultVal(item, 'type', PropertyGridUtils.valueToControlType(selectedObject[propertyName])), 29 | label: this.defaultVal(item, 'name', propertyName), 30 | browsable: this.defaultVal(item, 'browsable', true), 31 | description: this.defaultVal(item, 'description', ''), 32 | group: this.defaultVal(item, 'group', PROPERTY_GRID_CONSTANTS.OTHER_GROUP_NAME), 33 | showHelp: this.defaultVal(item, 'showHelp', false), 34 | options: this.defaultVal(item, 'options', {}), 35 | }); 36 | ID_COUNTER++; 37 | }); 38 | 39 | return config; 40 | } 41 | 42 | private defaultVal(item: DataObject, property: string, defaultValue: any): any { 43 | return PropertyGridUtils.getDefaultVal(item, property, defaultValue); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/controls/property-grid-row.ts: -------------------------------------------------------------------------------- 1 | import { FormControlComposite } from './form-control-composite'; 2 | import { IFormControl } from './iform-control'; 3 | 4 | export class PropertyGridRow extends FormControlComposite { 5 | private propertyGridRowCssClass = 'property-grid-row'; 6 | private propertyGridCellCssClass = 'property-grid-cell'; 7 | public render(): HTMLTableRowElement { 8 | const el = this.getNativeElement(); 9 | el.innerHTML = ''; 10 | 11 | Object.keys(this.controls).forEach((controlName) => { 12 | el.appendChild(this.createLabelCell(this.controls[controlName])); 13 | el.appendChild(this.createControls(this.controls[controlName])); 14 | }); 15 | 16 | return el; 17 | } 18 | 19 | getNativeElement(): HTMLTableRowElement { 20 | return super.getNativeElement() as HTMLTableRowElement; 21 | } 22 | 23 | getNativeElementType(): string { 24 | return 'tr'; 25 | } 26 | 27 | createNatveElement(): void { 28 | super.createNatveElement(); 29 | this.nativeElement.id = this.nativeElement.id + 'Row'; 30 | this.getNativeElement().classList.add(this.propertyGridRowCssClass); 31 | } 32 | // eslint-disable-next-line @typescript-eslint/no-empty-function 33 | attachEventListeners(): void {} 34 | // eslint-disable-next-line @typescript-eslint/no-empty-function 35 | removeEventListeners(): void {} 36 | 37 | private createLabelCell(control: IFormControl): HTMLTableCellElement { 38 | const cell = this.createGridCell(); 39 | cell.appendChild(document.createTextNode(control.getLabel())); 40 | 41 | if (control.getDescription() && control.getOptions().showHelp) { 42 | const helpTooltip = document.createElement('span'); 43 | helpTooltip.innerHTML = ' [?]'; 44 | helpTooltip.title = control.getDescription(); 45 | 46 | cell.appendChild(helpTooltip); 47 | } 48 | 49 | return cell; 50 | } 51 | 52 | private createControls(control: IFormControl): HTMLTableCellElement { 53 | const cell = this.createGridCell(); 54 | 55 | cell.appendChild(control.render()); 56 | return cell; 57 | } 58 | 59 | private createGridCell(): HTMLTableCellElement { 60 | const el = document.createElement('td'); 61 | el.classList.add(this.propertyGridCellCssClass); 62 | return el; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/controls/iform-control.ts: -------------------------------------------------------------------------------- 1 | import { Attributes, FormEvent, Options } from '../models/types'; 2 | 3 | export interface IFormControl { 4 | /** 5 | * Method called from init to attach event listeners like click, change..etc. 6 | */ 7 | attachEventListeners(): void; 8 | 9 | /** 10 | * A callback method that is called only once during init. 11 | * Return the DOM element of your component, this is what the property grid puts into the DOM. 12 | * Creates the native element based on props. 13 | */ 14 | createNatveElement(): void; 15 | 16 | /** 17 | * A callback method that performs custom clean-up, invoked immediately 18 | * before a component instance is destroyed. 19 | */ 20 | destroy(): void; 21 | 22 | /** 23 | * Returns Component Attributes 24 | */ 25 | getAttributes(): Attributes; 26 | 27 | /** 28 | * Returns Component Id 29 | */ 30 | getId(): string; 31 | 32 | /** 33 | * Returns component name 34 | */ 35 | getName(): string; 36 | 37 | /** 38 | * Returns the Native Element 39 | */ 40 | getNativeElement(): HTMLElement; 41 | 42 | /** 43 | * Returns Native Element type. For ex: input, select...etc. 44 | */ 45 | getNativeElementType(): string; 46 | 47 | /** 48 | * Returns component label (title). 49 | */ 50 | getLabel(): string; 51 | /** 52 | * Returns component label (title). 53 | */ 54 | getDescription(): string; 55 | 56 | /** 57 | * Returns component options 58 | */ 59 | getOptions(): Options; 60 | 61 | /** 62 | * Returns Component Value 63 | */ 64 | getValue(): any; 65 | 66 | /** 67 | * A callback method that is invoked only once when the component is instantiated. 68 | */ 69 | init(): void; 70 | 71 | /** 72 | * A callback method that is called before destory to remove all attached event listeners. 73 | */ 74 | removeEventListeners(): void; 75 | 76 | /** 77 | * A callback function that is called when the native element should be rendered on UI. 78 | * Could be called multiple times to reflect component state. 79 | */ 80 | render(): HTMLElement; 81 | 82 | /** 83 | * A callback function that is called to set the component value 84 | * @param value 85 | */ 86 | setValue(value: any): void; 87 | 88 | /** 89 | * Optional on Value change event handler, that will be used by hooks and handlers. 90 | */ 91 | onValueChange?: (event: FormEvent) => void; 92 | } 93 | -------------------------------------------------------------------------------- /src/controls/property-grid-row-group.ts: -------------------------------------------------------------------------------- 1 | import { PropertyGridFormControlsMap } from '../controls/property-grid-form-controls-map'; 2 | import { DataObject } from '../models/types'; 3 | 4 | import { FormControlParams } from './form-control'; 5 | import { FormControlComposite } from './form-control-composite'; 6 | 7 | export class PropertyGridRowGroup extends FormControlComposite { 8 | protected controls: PropertyGridFormControlsMap = {}; 9 | 10 | constructor(params: FormControlParams) { 11 | super(params); 12 | this.onToggleGroupVisibility = this.onToggleGroupVisibility.bind(this); 13 | } 14 | 15 | public render(): any { 16 | const el = this.getNativeElement(); 17 | 18 | el.querySelector('.property-grid-group-content table').appendChild(super.render()); 19 | return el; 20 | } 21 | 22 | public getData(): DataObject { 23 | let result = {}; 24 | for (const name in this.controls) { 25 | if (this.controls[name]) { 26 | result = Object.assign(result, this.controls[name].getData()); 27 | } 28 | } 29 | 30 | return result; 31 | } 32 | 33 | getNativeElementType(): string { 34 | return 'tr'; 35 | } 36 | 37 | createNatveElement(): void { 38 | const el = document.createElement(this.getNativeElementType()); 39 | el.classList.add('property-grid-row-group'); 40 | 41 | const innerHTML = ` 42 | 43 | 44 | 45 | 48 |
${this.getLabel()}
46 |
47 |
49 | 50 | `; 51 | el.innerHTML = innerHTML; 52 | this.nativeElement = el; 53 | } 54 | attachEventListeners(): void { 55 | this.getNativeElement() 56 | .querySelector('.property-grid-row-group-wrapper .property-grid-group') 57 | .addEventListener('click', this.onToggleGroupVisibility, false); 58 | } 59 | removeEventListeners(): void { 60 | this.getNativeElement() 61 | .querySelector('.property-grid-row-group-wrapper .property-grid-group') 62 | .removeEventListener('click', this.onToggleGroupVisibility); 63 | } 64 | 65 | protected onToggleGroupVisibility(): void { 66 | this.getNativeElement().querySelector('.property-grid-group-content').classList.toggle('collapsed'); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/services/property-grid-groups-builder-service.ts: -------------------------------------------------------------------------------- 1 | import { PropertyGridGroup, PropertyGridItem } from '../models'; 2 | import { PropertyGridConfig, PropertyGridConfigItem } from '../models'; 3 | import { PropertyGridUtils } from '../utils/property-grid-utils'; 4 | 5 | export class PropertyGridGroupsBuilderService { 6 | private OTHER_GROUP_KEY = 'other'; 7 | private OTHER_GROUP_NAME = 'Other'; 8 | 9 | buildGroups(config: PropertyGridConfig, sortProperties: boolean | ((params: string[]) => string[])): PropertyGridGroup[] { 10 | const groupsMap = {}; 11 | const otherGroup = { 12 | label: this.OTHER_GROUP_NAME, 13 | name: this.OTHER_GROUP_KEY, 14 | children: [], 15 | }; 16 | 17 | const properties = this.sortProperties(Object.keys(config), sortProperties); 18 | 19 | properties.forEach((prop) => { 20 | const item = config[prop]; 21 | if (this.isNotBrowsable(item)) { 22 | return; 23 | } 24 | 25 | const itemGroupLabel = item.group; 26 | const itemGroupKey = PropertyGridUtils.camelize(itemGroupLabel); 27 | 28 | if (!groupsMap.hasOwnProperty(itemGroupKey)) { 29 | groupsMap[itemGroupKey] = this.createGroup(itemGroupLabel, itemGroupKey); 30 | } 31 | 32 | if (itemGroupKey === 'other') { 33 | otherGroup.children.push(this.createGridItem(item)); 34 | } else { 35 | groupsMap[itemGroupKey].children.push(item); 36 | } 37 | }); 38 | groupsMap[this.OTHER_GROUP_KEY] = otherGroup; 39 | const groups = this.sortProperties(Object.keys(groupsMap), sortProperties).map((key) => groupsMap[key]); 40 | 41 | return groups; 42 | } 43 | 44 | private createGridItem(configItem: PropertyGridConfigItem): PropertyGridItem { 45 | return { 46 | id: configItem.id, 47 | name: configItem.name, 48 | type: configItem.type, 49 | description: configItem.description, 50 | label: configItem.label, 51 | attributes: configItem.options, 52 | options: { 53 | group: configItem.group, 54 | browsable: configItem.browsable, 55 | showHelp: configItem.showHelp, 56 | items: configItem.items || [], 57 | }, 58 | }; 59 | } 60 | 61 | private createGroup(groupLabel, groupKey): PropertyGridGroup { 62 | return { 63 | name: groupKey, 64 | label: groupLabel, 65 | children: [], 66 | }; 67 | } 68 | 69 | private isNotBrowsable(item: PropertyGridConfigItem): boolean { 70 | return item.hasOwnProperty('browsable') && item.browsable === false; 71 | } 72 | 73 | private sortProperties(properties: string[], sortMethod?: boolean | ((params: string[]) => string[])): string[] { 74 | if (sortMethod === false) { 75 | return properties; 76 | } 77 | return sortMethod instanceof Function ? sortMethod(properties) : properties.sort(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /*eslint-disable */ 2 | var path = require('path'); 3 | 4 | var webpack = require('webpack'); 5 | 6 | var PATHS = { 7 | entryPoint: path.resolve(__dirname, 'src/index.ts'), 8 | bundles: path.resolve(__dirname, 'dist/bundles'), 9 | }; 10 | 11 | var config = { 12 | // These are the entry point of our library. We tell webpack to use 13 | // the name we assign later, when creating the bundle. We also use 14 | // the name to filter the second entry point for applying code 15 | // minification via UglifyJS 16 | entry: { 17 | 'clean-web-ui-property-grid': [PATHS.entryPoint], 18 | 'clean-web-ui-property-grid.min': [PATHS.entryPoint], 19 | }, 20 | // The output defines how and where we want the bundles. The special 21 | // value `[name]` in `filename` tell Webpack to use the name we defined above. 22 | // We target a UMD and name it MyLib. When including the bundle in the browser 23 | // it will be accessible at `window.MyLib` 24 | output: { 25 | path: PATHS.bundles, 26 | filename: '[name].js', 27 | libraryTarget: 'umd', 28 | library: 'CleanWebUIPropertyGrid', 29 | umdNamedDefine: true, 30 | globalObject: 'this', 31 | }, 32 | // Add resolve for `tsx` and `ts` files, otherwise Webpack would 33 | // only look for common JavaScript file extension (.js) 34 | resolve: { 35 | extensions: ['.ts', '.js'], 36 | }, 37 | // Activate source maps for the bundles in order to preserve the original 38 | // source when the user debugs the application 39 | devtool: 'source-map', 40 | plugins: [new webpack.HotModuleReplacementPlugin()], 41 | module: { 42 | // Webpack doesn't understand TypeScript files and a loader is needed. 43 | // `node_modules` folder is excluded in order to prevent problems with 44 | // the library dependencies, as well as `__tests__` folders that 45 | // contain the tests for the library 46 | rules: [ 47 | { 48 | test: /\.ts?$/, 49 | loader: 'awesome-typescript-loader', 50 | exclude: /node_modules/, 51 | query: { 52 | // we don't want any declaration file in the bundles 53 | // folder since it wouldn't be of any use ans the source 54 | // map already include everything for debugging 55 | declaration: false, 56 | }, 57 | }, 58 | ], 59 | }, 60 | stats: { 61 | colors: { 62 | green: '\u001b[32m', 63 | }, 64 | }, 65 | 66 | devServer: { 67 | contentBase: './src', 68 | historyApiFallback: true, 69 | port: 3000, 70 | compress: false, 71 | inline: true, 72 | hot: true, 73 | stats: { 74 | assets: true, 75 | children: false, 76 | chunks: false, 77 | hash: false, 78 | modules: false, 79 | publicPath: false, 80 | timings: true, 81 | version: false, 82 | warnings: true, 83 | colors: { 84 | green: '\u001b[32m', 85 | }, 86 | }, 87 | }, 88 | }; 89 | 90 | module.exports = config; 91 | -------------------------------------------------------------------------------- /src/controls/form-control.ts: -------------------------------------------------------------------------------- 1 | import { Attributes, Options } from '../models/types'; 2 | 3 | import { IFormControl } from './iform-control'; 4 | 5 | export interface FormControlParams { 6 | id: string; 7 | name: string; 8 | label: string; 9 | description: string; 10 | attributes: Attributes; 11 | options: Options; 12 | } 13 | 14 | /** 15 | * The base Component class declares an interface for all concrete components, 16 | * both simple and complex. 17 | * 18 | */ 19 | export abstract class FormControl implements IFormControl { 20 | protected id: string; 21 | protected name: string; 22 | protected label: string; 23 | protected description: string; 24 | protected value: any; 25 | protected attributes: Attributes; 26 | protected options: Options; 27 | protected nativeElement: HTMLElement; 28 | 29 | constructor(params: FormControlParams) { 30 | Object.assign(this, params); 31 | this.options = params.options || { showHelp: true }; 32 | this.attributes = params.attributes || {}; 33 | } 34 | 35 | /** 36 | * Use to attach event listeners like click, change...etc. 37 | * It is called inside createNativeElement 38 | */ 39 | abstract attachEventListeners(): void; 40 | 41 | /** 42 | * A callback method that is called before destory to remove all attached event listeners. 43 | */ 44 | abstract removeEventListeners(): void; 45 | 46 | /** 47 | * Each concrete DOM element must provide its rendering implementation, but 48 | * we can safely assume that all of them are returning strings. 49 | */ 50 | abstract render(): HTMLElement; 51 | 52 | /** Returns native element type. Ex: input, select */ 53 | abstract getNativeElementType(): string; 54 | 55 | /** 56 | * Initialize component, creates native element, attach event listners 57 | */ 58 | init(): void { 59 | if (this.nativeElement) { 60 | return; 61 | } 62 | this.createNatveElement(); 63 | this.attachEventListeners(); 64 | } 65 | 66 | /** 67 | * Cleanup form control and removes all event listeners 68 | */ 69 | destroy(): void { 70 | if (!this.getNativeElement()) { 71 | return; 72 | } 73 | 74 | this.removeEventListeners(); 75 | this.nativeElement = null; 76 | } 77 | 78 | /** 79 | * Creates native element and attach event listeners 80 | */ 81 | createNatveElement(): void { 82 | if (this.nativeElement) { 83 | return; 84 | } 85 | 86 | this.nativeElement = document.createElement(this.getNativeElementType()); 87 | 88 | this.nativeElement.id = this.id; 89 | } 90 | 91 | getAttributes(): Attributes { 92 | return this.attributes; 93 | } 94 | 95 | getId(): string { 96 | return this.id; 97 | } 98 | 99 | getName(): string { 100 | return this.name; 101 | } 102 | 103 | getDescription(): string { 104 | return this.description; 105 | } 106 | 107 | getNativeElement(): HTMLElement { 108 | if (!this.nativeElement) { 109 | throw Error('You can not use native element before is being initialized!'); 110 | } 111 | 112 | return this.nativeElement; 113 | } 114 | 115 | getLabel(): string { 116 | return this.label; 117 | } 118 | 119 | getOptions(): Options { 120 | return this.options; 121 | } 122 | 123 | getValue(): any { 124 | return this.value; 125 | } 126 | 127 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 128 | setValue(value: any): void { 129 | this.value = value; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | /*eslint-disable */ 2 | var path = require('path'); 3 | 4 | var TerserPlugin = require('terser-webpack-plugin'); 5 | const CopyPlugin = require('copy-webpack-plugin'); 6 | var webpack = require('webpack'); 7 | 8 | var PATHS = { 9 | entryPoint: path.resolve(__dirname, 'src/index.ts'), 10 | bundles: path.resolve(__dirname, 'dist/bundles'), 11 | }; 12 | 13 | console.log('Prod config'); 14 | 15 | var config = { 16 | // These are the entry point of our library. We tell webpack to use 17 | // the name we assign later, when creating the bundle. We also use 18 | // the name to filter the second entry point for applying code 19 | // minification via UglifyJS 20 | entry: { 21 | 'clean-web-ui-property-grid': [PATHS.entryPoint], 22 | 'clean-web-ui-property-grid.min': [PATHS.entryPoint], 23 | }, 24 | // The output defines how and where we want the bundles. The special 25 | // value `[name]` in `filename` tell Webpack to use the name we defined above. 26 | // We target a UMD and name it MyLib. When including the bundle in the browser 27 | // it will be accessible at `window.MyLib` 28 | output: { 29 | path: PATHS.bundles, 30 | filename: '[name].js', 31 | libraryTarget: 'umd', 32 | library: 'CleanWebUIPropertyGrid', 33 | umdNamedDefine: true, 34 | globalObject: 'this', 35 | }, 36 | // Add resolve for `tsx` and `ts` files, otherwise Webpack would 37 | // only look for common JavaScript file extension (.js) 38 | resolve: { 39 | extensions: ['.ts', '.js'], 40 | }, 41 | // Activate source maps for the bundles in order to preserve the original 42 | // source when the user debugs the application 43 | devtool: 'source-map', 44 | plugins: [ 45 | new CopyPlugin({ 46 | patterns: [ 47 | { 48 | from: path.resolve(__dirname, 'README.md'), 49 | to: path.resolve(__dirname, 'dist'), 50 | }, 51 | { 52 | from: path.resolve(__dirname, 'package.json'), 53 | to: path.resolve(__dirname, 'dist'), 54 | }, 55 | { 56 | from: path.resolve(__dirname, 'LICENSE'), 57 | to: path.resolve(__dirname, 'dist'), 58 | }, 59 | { 60 | from: path.resolve(__dirname, '.npmignore'), 61 | to: path.resolve(__dirname, 'dist'), 62 | }, 63 | ], 64 | }), 65 | ], 66 | module: { 67 | // Webpack doesn't understand TypeScript files and a loader is needed. 68 | // `node_modules` folder is excluded in order to prevent problems with 69 | // the library dependencies, as well as `__tests__` folders that 70 | // contain the tests for the library 71 | rules: [ 72 | { 73 | test: /\.ts?$/, 74 | loader: 'awesome-typescript-loader', 75 | exclude: /node_modules/, 76 | query: { 77 | // we don't want any declaration file in the bundles 78 | // folder since it wouldn't be of any use ans the source 79 | // map already include everything for debugging 80 | declaration: false, 81 | }, 82 | }, 83 | ], 84 | }, 85 | optimization: { 86 | minimizer: [ 87 | // we specify a custom UglifyJsPlugin here to get source maps in production 88 | new TerserPlugin({ 89 | parallel: true, 90 | include: /\.min\.js$/, 91 | terserOptions: { 92 | ecma: 6, 93 | }, 94 | }), 95 | ], 96 | }, 97 | stats: { 98 | colors: { 99 | green: '\u001b[32m', 100 | }, 101 | }, 102 | }; 103 | 104 | module.exports = config; 105 | -------------------------------------------------------------------------------- /src/styles/property-grid-base-style.ts: -------------------------------------------------------------------------------- 1 | const propertyGridStyles = document.createElement('template'); 2 | propertyGridStyles.innerHTML = ` 3 | 150 | `; 151 | 152 | export { propertyGridStyles }; 153 | -------------------------------------------------------------------------------- /src/sample/index.js: -------------------------------------------------------------------------------- 1 | function createPropertyGrid(id, config, options, data) { 2 | var propertyGridWrapper = document.getElementById(id); 3 | let propertyGrid = document.createElement('property-grid'); 4 | propertyGrid.id = 'pg' + id; 5 | propertyGrid.config = config; 6 | propertyGrid.options = options; 7 | propertyGrid.selectedObject = data; 8 | propertyGridWrapper.innerHTML = ''; 9 | propertyGridWrapper.appendChild(propertyGrid); 10 | } 11 | 12 | function getValues(target) { 13 | var grid = document.getElementById(target); 14 | document.getElementById('txtValues').value = JSON.stringify(grid.getValues(), null, 2); 15 | } 16 | 17 | function updateOptions() { 18 | var options = document.getElementById('pgpropGridOptions').getValues(); 19 | document.getElementById('pgpropertyGridWithConfig').options = options; 20 | } 21 | 22 | setTimeout(() => { 23 | var pgOptions = { 24 | preferredFormat: 'hex', 25 | showInput: true, 26 | showInitial: true, 27 | hasGroups: true, 28 | propertySort: true, 29 | toolbarVisible: true, 30 | onValueChange: (e) => { 31 | console.log(e); 32 | if (e.target.id == 'pgfilter2') { 33 | e.preventDefault(); 34 | e.stopPropagation(); 35 | return false; 36 | } 37 | }, 38 | }; 39 | 40 | var pgConfig = { 41 | dontShowMe: { browsable: false }, 42 | filter: { group: 'Behavior', name: 'Filter', type: 'boolean' }, 43 | filterSize: { 44 | group: 'Behavior', 45 | name: 'Filter size', 46 | type: 'number', 47 | options: { min: 0, max: 500, step: 10 }, 48 | }, 49 | accumulateTicks: { 50 | group: 'Behavior', 51 | name: 'Accumulate ticks', 52 | type: 'boolean', 53 | }, 54 | buyColor: { 55 | group: 'Appearance', 56 | name: 'Buy color', 57 | type: 'color', 58 | options: {}, 59 | }, 60 | sellColor: { 61 | group: 'Appearance', 62 | name: 'Sell color', 63 | type: 'color', 64 | options: { 65 | preferredFormat: 'hex', 66 | showInput: true, 67 | showInitial: true, 68 | }, 69 | }, 70 | someOption: { 71 | name: 'Some option', 72 | type: 'options', 73 | items: ['Yes', 'No'], 74 | }, 75 | readOnly: { 76 | name: 'Read Only', 77 | type: 'label', 78 | description: 'This is a label', 79 | showHelp: true, 80 | }, 81 | }; 82 | 83 | // var opts = { 84 | // propertySort: (props) => props.sort().reverse(), 85 | // }; 86 | 87 | var pgData = { 88 | accumulateTicks: true, 89 | filter: false, 90 | filterSize: 200, 91 | buyColor: '#00ff00', 92 | sellColor: '#ff0000', 93 | someOption: 'No', 94 | noGroup: 'I have no group', 95 | dontShowMe: 'please', 96 | readOnly: 'I am read only', 97 | }; 98 | 99 | createPropertyGrid('propertyGridWithConfig', pgConfig, pgOptions, pgData); 100 | 101 | document.getElementById('pgpropertyGridWithConfig').addEventListener('valueChanged', (v) => console.log(v)); 102 | 103 | const pg1 = document.getElementById('pg1'); 104 | pg1.selectedObject = pgData; 105 | 106 | // document.addEventListener('onValueChange', (v) => console.log(v)); 107 | 108 | var pgOptionsConfig = { 109 | hasGroups: { 110 | group: 'Groups', 111 | name: 'Has Groups', 112 | type: 'boolean', 113 | description: 'Gets or sets a value indicating whether controls are groupped and if groups are visible.', 114 | }, 115 | propertySort: { 116 | group: 'Controls', 117 | name: 'Properties sort', 118 | type: 'boolean', 119 | description: 'Gets or sets if PropertyGrid will sort properties by display name.', 120 | }, 121 | toolbarVisible: { 122 | group: 'Controls', 123 | name: 'Is Toolbar Visible', 124 | type: 'boolean', 125 | description: 'Gets or sets a value indicating whether the toolbar is visible.', 126 | }, 127 | }; 128 | createPropertyGrid('propGridOptions', pgOptionsConfig, pgOptions, pgOptions); 129 | }, 1000); 130 | -------------------------------------------------------------------------------- /src/controls/property-grid-form.ts: -------------------------------------------------------------------------------- 1 | import { PropertyGridFormControlsMap } from '../controls/property-grid-form-controls-map'; 2 | import { DataObject, FormEvent } from '../models/types'; 3 | 4 | import { FormControlParams } from './form-control'; 5 | import { FormControlComposite } from './form-control-composite'; 6 | import { PropertyGridRowGroup } from './property-grid-row-group'; 7 | 8 | /** 9 | * The fieldset element is a Concrete Composite. 10 | */ 11 | export class PropertyGridForm extends FormControlComposite { 12 | private propertyGridFormCssClass = 'property-grid-form'; 13 | private propertyGridTableCssClass = 'property-grid'; 14 | protected controls: PropertyGridFormControlsMap = {}; 15 | 16 | constructor(params: FormControlParams) { 17 | super(params); 18 | this.onFormSubmit = this.onFormSubmit.bind(this); 19 | this.onSearch = this.onSearch.bind(this); 20 | } 21 | 22 | createNatveElement(): void { 23 | super.createNatveElement(); 24 | const el = this.getNativeElement(); 25 | el.classList.add(this.propertyGridFormCssClass); 26 | 27 | const toolbar = document.createElement('header'); 28 | toolbar.classList.add('property-grid-header'); 29 | toolbar.innerHTML = ` 30 |
`; 31 | el.appendChild(toolbar); 32 | 33 | const fieldset = document.createElement('fieldset'); 34 | const pgTable = document.createElement('table'); 35 | pgTable.classList.add(this.propertyGridTableCssClass); 36 | 37 | fieldset.appendChild(pgTable); 38 | 39 | el.appendChild(fieldset); 40 | } 41 | 42 | public render(): any { 43 | const el = this.getNativeElement(); 44 | const table = el.querySelector('table'); 45 | table.appendChild(super.render()); 46 | return el; 47 | } 48 | 49 | public setData(data: DataObject): void { 50 | for (const name in this.controls) { 51 | if (this.controls[name] && this.isRowGroupCtrl(this.controls[name])) { 52 | this.controls[name].setValue(data); 53 | } 54 | } 55 | } 56 | 57 | public getData(): any { 58 | let response = {}; 59 | for (const name in this.controls) { 60 | if (this.controls[name] && this.isRowGroupCtrl(this.controls[name])) { 61 | response = Object.assign(response, this.controls[name].getData()); 62 | } 63 | } 64 | 65 | return response; 66 | } 67 | 68 | private isRowGroupCtrl(ctrl): boolean { 69 | return ctrl.constructor.name.toString() === PropertyGridRowGroup.name.toString(); 70 | } 71 | 72 | getNativeElementType(): string { 73 | return 'form'; 74 | } 75 | 76 | setDisabled(isDisabled: boolean): void { 77 | this.getNativeElement().querySelector('fieldset').disabled = isDisabled; 78 | } 79 | 80 | toggleToolbar(isToolbarVisible: boolean): void { 81 | if (!isToolbarVisible) { 82 | this.getNativeElement().querySelector('.property-grid-header').classList.toggle('hidden'); 83 | } else { 84 | this.getNativeElement().querySelector('.property-grid-header').classList.remove('hidden'); 85 | } 86 | } 87 | 88 | attachEventListeners(): void { 89 | this.getNativeElement().addEventListener('submit', this.onFormSubmit); 90 | this.getNativeElement().querySelector('.property-grid-header input').addEventListener('input', this.onSearch); 91 | } 92 | removeEventListeners(): void { 93 | this.getNativeElement().removeEventListener('submit', this.onFormSubmit); 94 | this.getNativeElement().querySelector('.property-grid-header input').removeEventListener('input', this.onSearch); 95 | } 96 | 97 | onFormSubmit(event: FormEvent): void { 98 | event.preventDefault(); 99 | } 100 | 101 | onSearch(event: FormEvent): void { 102 | event.preventDefault(); 103 | const value = event.target.value.toLowerCase(); 104 | 105 | value ? this.getNativeElement().classList.add('no-groups') : this.getNativeElement().classList.remove('no-groups'); 106 | this.getNativeElement() 107 | .querySelectorAll('.property-grid-row') 108 | .forEach((row) => row.classList.remove('hidden')); 109 | 110 | this.getNativeElement() 111 | .querySelectorAll('.property-grid-row td:first-of-type') 112 | .forEach((cell) => { 113 | if (!cell.textContent.toLowerCase().startsWith(value)) { 114 | cell.parentElement.classList.add('hidden'); 115 | } 116 | }); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/property-grid.component.ts: -------------------------------------------------------------------------------- 1 | import { PropertyGridForm } from './controls'; 2 | import { PropertyGridConfig, PropertyGridOptions } from './models'; 3 | import { DataObject, PropertyGridEvents } from './models/types'; 4 | import { propertyGridTemplate } from './property-grid-template'; 5 | import { EventDispatcherService, Logger, PropertyGridGroupsBuilderService, PropertyGridService } from './services'; 6 | import { ConfigParserService } from './services/config-parser-service'; 7 | import { PropertyGridFactory } from './services/property-grid-factory'; 8 | import { propertyGridStyles } from './styles/property-grid-base-style'; 9 | import { PropertyGridUtils } from './utils/property-grid-utils'; 10 | 11 | export class PropertyGrid extends HTMLElement { 12 | private _defaultOptions: PropertyGridOptions = { 13 | canFocus: true, 14 | controls: {}, 15 | helpVisible: true, 16 | propertySort: true, 17 | toolbarVisible: true, 18 | }; 19 | 20 | private _options: PropertyGridOptions; 21 | private _config: PropertyGridConfig; 22 | private _root; 23 | private _propertyGridEl: HTMLDivElement; 24 | private _gridReady = false; 25 | private _selectedObject; 26 | private readonly _selectedGridItem; 27 | private propertyGridForm: PropertyGridForm; 28 | private eventListeners = {}; 29 | private groupsBuilder: PropertyGridGroupsBuilderService; 30 | private configParser: ConfigParserService; 31 | private eventDispatcher: EventDispatcherService; 32 | private factory: PropertyGridFactory; 33 | private pgBuilder: PropertyGridService; 34 | 35 | static get observedAttributes(): string[] { 36 | return ['disabled']; 37 | } 38 | 39 | constructor() { 40 | super(); 41 | 42 | // Create a new shadow dom root. 43 | // The mode describes, if the node can be accessed from the outside. 44 | this.attachShadow({ mode: 'open' }); 45 | 46 | this._root = this.shadowRoot; 47 | 48 | // Fill the shadow dom with the template by a deep clone. 49 | this._root.appendChild(propertyGridStyles.content.cloneNode(true)); 50 | this._root.appendChild(propertyGridTemplate.content.cloneNode(true)); 51 | 52 | this._options = this._defaultOptions; 53 | 54 | this._propertyGridEl = this.shadowRoot.querySelector('#propertyGrid'); 55 | this.groupsBuilder = new PropertyGridGroupsBuilderService(); 56 | this.configParser = new ConfigParserService(); 57 | this.eventDispatcher = new EventDispatcherService(); 58 | this.factory = new PropertyGridFactory(this.eventDispatcher); 59 | this.pgBuilder = new PropertyGridService(this.factory); 60 | 61 | this.onValueChanged = this.onValueChanged.bind(this); 62 | 63 | this.eventListeners['onValueChanged'] = this.eventDispatcher.register(PropertyGridEvents.onValueChanged, this.onValueChanged); 64 | } 65 | 66 | set config(opts: PropertyGridConfig) { 67 | this._config = Object.assign({}, opts); 68 | } 69 | 70 | get config(): PropertyGridConfig { 71 | return this._config; 72 | } 73 | 74 | get disabled(): boolean { 75 | return this.hasAttribute('disabled'); 76 | } 77 | 78 | set disabled(val: boolean) { 79 | if (val) { 80 | this.setAttribute('disabled', ''); 81 | } else { 82 | this.removeAttribute('disabled'); 83 | } 84 | } 85 | 86 | set options(opts: PropertyGridOptions) { 87 | this._options = Object.assign(this._defaultOptions, opts); 88 | 89 | this._options = opts; 90 | 91 | if (this._gridReady) { 92 | this.render(); 93 | } 94 | } 95 | 96 | get options(): PropertyGridOptions { 97 | return this._options; 98 | } 99 | 100 | set selectedObject(obj: DataObject) { 101 | if (typeof obj === 'string') { 102 | Logger.getInstance().error('PropertyGrid got invalid option:', obj); 103 | return; 104 | } else if (typeof obj !== 'object' || obj === null) { 105 | Logger.getInstance().error('PropertyGrid must get an object in order to initialize the grid.'); 106 | return; 107 | } 108 | this._selectedObject = obj; 109 | this.render(); 110 | } 111 | 112 | get selectedObject(): DataObject { 113 | return this.selectedObject; 114 | } 115 | 116 | get selectedGridItem(): any { 117 | return this._selectedGridItem; 118 | } 119 | 120 | connectedCallback(): void { 121 | this.render(); 122 | } 123 | disconnectedCallback(): void { 124 | this.destroy(); 125 | } 126 | 127 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 128 | attributeChangedCallback(name: string, oldValue: any, newValue: any): void { 129 | if (oldValue === newValue) { 130 | return; 131 | } 132 | if (name === 'disabled') { 133 | if (this.disabled) { 134 | this.setAttribute('tabindex', '-1'); 135 | this.setAttribute('aria-disabled', 'true'); 136 | } else { 137 | this.setAttribute('tabindex', '0'); 138 | this.setAttribute('aria-disabled', 'false'); 139 | } 140 | 141 | if (this._gridReady) { 142 | this.propertyGridForm.setDisabled(this.disabled); 143 | } 144 | } 145 | } 146 | 147 | /** 148 | * Forces the control to invalidate its client area and immediately redraw itself and any child controls. 149 | */ 150 | render(): void { 151 | if (!this._selectedObject) { 152 | return; 153 | } 154 | 155 | if (this._options && this._options.controls) { 156 | this.factory.registerControls(this._options.controls); 157 | } 158 | 159 | const origConfig = this._config ? PropertyGridUtils.deepCopy(this._config) : null; 160 | const config = this.configParser.parse(this._selectedObject, origConfig); 161 | 162 | if (this.options.hasGroups === false) { 163 | this.configParser.assignToGroup(config); 164 | } 165 | 166 | const groups = this.groupsBuilder.buildGroups(config, this.options.propertySort); 167 | this.propertyGridForm = this.pgBuilder.build(groups, this._options); 168 | this._propertyGridEl.innerHTML = ''; 169 | this._propertyGridEl.appendChild(this.propertyGridForm.render()); 170 | 171 | this.propertyGridForm.setData(this._selectedObject); 172 | 173 | this.propertyGridForm.setDisabled(this.disabled); 174 | this.propertyGridForm.toggleToolbar(this.options.toolbarVisible); 175 | if (this.options.hasGroups === false) { 176 | this.propertyGridForm.getNativeElement().classList.add('no-groups'); 177 | } 178 | 179 | this._gridReady = true; 180 | } 181 | 182 | getValues(): DataObject { 183 | return Object.assign(this._selectedObject, this.propertyGridForm.getData()); 184 | } 185 | 186 | destroy(): void { 187 | for (const key in this.eventListeners) { 188 | this.eventListeners[key].unsubscribe(); 189 | } 190 | this.eventDispatcher.reset(); 191 | this.propertyGridForm.destroy(); 192 | } 193 | 194 | private onValueChanged(data) { 195 | if (!this._gridReady) { 196 | return; 197 | } 198 | 199 | this.dispatchEvent(new CustomEvent('valueChanged', { detail: data })); 200 | } 201 | } 202 | customElements.define('property-grid', PropertyGrid); 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Property Grid 2 | 3 | - [Overview](#Overview) 4 | - [Installation](#Installation) 5 | - [Usage](#Usage) 6 | - [With Typescript](#with-typescript) 7 | - [With JavaScript](#with-javascript) 8 | - [Advanced](#advanced) 9 | - [API](#API) 10 | - [Properties](#Properties) 11 | - [Methods](#Methods) 12 | - [Events](#Events) 13 | - [Hooks](#Hooks) 14 | - [Styling](#Styling) 15 | 16 | ## Overview 17 | 18 | A small and simple property grid, written in pure Vanilla JavaScript (web components), inspired by [jqPropertyGrid](https://github.com/ValYouW/jqPropertyGrid). 19 | 20 | With this PropertyGrid control you can pass object and allow users to edit the properties with the data type-specific editors. The component offers the ability to group and sort its items, use custom editors per data type, work with and without configuration...etc. 21 | 22 | ![Property Grid](docs/images/pg.jpg?raw=true 'Property Grid') 23 | 24 | ## Installation 25 | 26 | Install with npm: 27 | 28 | `npm i clean-web-ui-property-grid --save` 29 | 30 | ## Usage 31 | 32 | ### With Typescript 33 | 34 | If you are using typescript: 35 | ```JS 36 | // In your main file 37 | import 'clean-web-ui-property-grid'; 38 | 39 | const pgOptions = { 40 | hasGroups: true, 41 | propertySort: true, 42 | toolbarVisible: true, 43 | } as PropertyGridOptions; 44 | 45 | const pgData = { 46 | filter: true, 47 | filterSize: 200, 48 | }; 49 | 50 | const pg1: PropertyGrid = document.getElementById('pg1') as PropertyGrid; 51 | pg1.options = pgOptions; 52 | pg1.selectedObject = pgData; 53 | 54 | // To listen for value changes 55 | pg1.addEventListener('valueChanged', (v) => console.log(v)); 56 | 57 | // To get values from property grid 58 | document.querySelector('#getValuesBtn').addEventListener('click', () => { 59 | console.log(pg1.getValues()); 60 | }); 61 | 62 | ``` 63 | 64 | Html markup 65 | ```HTML 66 | 67 | ``` 68 | 69 | ### With JavaScript 70 | 71 | With JavaScript, just import the script in the html body: 72 | 73 | `` 74 | 75 | 76 | You can use by inserting web component in html: 77 | 78 | ```html 79 | 80 | ``` 81 | 82 | With JS: 83 | 84 | ```JS 85 | const pg1 = document.getElementById('pg1'); 86 | pg1.selectedObject = pgData; 87 | ``` 88 | 89 | ### Advanced 90 | 91 | More complex example would be to pass config and options to the grid. 92 | 93 | In HTML: 94 | 95 | ```html 96 |
97 | ``` 98 | 99 | In Js/Typescript: 100 | 101 | ```JS 102 | //If using Typescript 103 | import {createPropertyGrid} from 'clean-web-ui-property-grid'; 104 | 105 | var pgOptions = { 106 | hasGroups: true, 107 | propertySort: true 108 | }; 109 | 110 | var pgConfig = { 111 | filter: { group: 'Behavior', name: 'Filter', type: 'boolean' }, 112 | filterSize: { 113 | group: 'Behavior', 114 | name: 'Filter size', 115 | type: 'number', 116 | options: { min: 0, max: 500, step: 10 }, 117 | } 118 | }; 119 | 120 | var pgData = { 121 | filter: true, 122 | filterSize: 200 123 | }; 124 | 125 | function createPropertyGrid(id, config, options, data) { 126 | var propertyGridWrapper = document.getElementById(id); 127 | let propertyGrid = document.createElement('property-grid'); 128 | propertyGrid.id = 'pg' + id; 129 | propertyGrid.config = config; 130 | propertyGrid.options = options; 131 | propertyGrid.selectedObject = data; 132 | propertyGridWrapper.innerHTML = ''; 133 | propertyGridWrapper.appendChild(propertyGrid); 134 | } 135 | 136 | createPropertyGrid('propertyGridWithConfig', pgConfig, pgOptions, pgData); 137 | 138 | // To listen for events emmited from grid 139 | document.getElementById('pgpropertyGridWithConfig').addEventListener('valueChanged', (v) => console.log(v)); 140 | ``` 141 | 142 | ## API 143 | 144 | ### Properties 145 | 146 | **_config_** - PropertyGridConfig | null 147 | This is the metadata object that describes the target object properties. 148 | Here you can pass and configure how the property grid editors will work like: control type, min/max values...etc. 149 | 150 | ```JS 151 | { 152 | "browsable": boolean // Whether this property should be included in the grid, default is true (can be omitted), 153 | "group": string // The group this property belongs to 154 | "name": string // The display name of the property in the grid 155 | "type": 'text' | 'number' | 'boolean' | 'color' | 'options' | 'label' // The type of the property 156 | "description": string // A description of the property, will be used as tooltip on an hint element (a span with text "[?]") 157 | "showHelp": boolean // If set to false, will disable showing description's span with text "[?]" on property name. 158 | "items": string[] // List of Dropdown Items used for select control 159 | "options": Object // An extra options object per type (attributes per control) 160 | } 161 | ``` 162 | 163 | **_options_** - PropertyGridOptions | null 164 | The options that are passed to the property grid control to configure how the grid will work. Here you pass options that configure the grid itself, not the individual controls. You can pass options like: show/hide groups, sort method...etc. 165 | 166 | ```JS 167 | { 168 | "hasGroups": boolean //Gets or sets a value indicating whether controls are groupped and if groups are visible 169 | "propertySort": boolean | Function //If and how to sort properties in the grid. Optionally you can pass callback functiom 170 | "onValueChange": FormControl // Hook that is called before value is changed 171 | "controls": Function // Set custom controls that will be used for rendering 172 | "enabled": boolean // Gets or sets a value indicating whether the control can respond to user interaction. 173 | "toolbarVisible": boolean // Gets or sets a value indicating whether the toolbar is visible. 174 | } 175 | ``` 176 | 177 | **_selectedObject_** - Object 178 | The is the object that you want to edit in the grid. If no config is passed to the grid, the grid will try to create config based on object properties and value type. 179 | 180 | **_disabled_** - boolean 181 | Gets or sets a value indicating whether the control can respond to user interaction. 182 | 183 | ### Methods 184 | 185 | **_render()_** - void 186 | Build the config for the grid, render controls on UI and attach event listeners. Called internally by the grid when selectedObject is changed. 187 | 188 | **_getValues()_** - Object 189 | Returns the current values for all properties in the grid. 190 | 191 | **_destroy()_** - void 192 | Destroys all elements and removes all event listners from elements. 193 | Called internally by the grid when the component is about to be destroyed. 194 | 195 | ## Events 196 | 197 | **_valueChanged_** - CustomEvent 198 | Fired when the value is changed for some of controls in the property grid. 199 | 200 | Event payload: 201 | 202 | `{name: 'controlname', value: 'new value'}` 203 | 204 | ## Hooks 205 | 206 | **_onValueChange(event)_** - Object | boolean | void 207 | Hook that is called before value is changed. Usefull if you want to intercept the original valueChange event. 208 | 209 | ## Styling 210 | 211 | Available CSS variables 212 | 213 | ```JS 214 | --property-grid-header-background: #E0ECFF; 215 | --property-grid-header-border: 1px dotted #95B8E7; 216 | --property-grid-table-row-group-background: #E0ECFF; 217 | --property-grid-table-row-group-font-weight: bold; 218 | --property-grid-table-row-hover: #f0f5fd; 219 | --property-grid-cell-border: 1px dotted #ccc; 220 | ``` 221 | --------------------------------------------------------------------------------