RS pack port and tgui core 3.0.4 (#17520)

* RS pack initial

* fix RPD

* urg wrong tgui folder

* tgui core 3.0.4

* update tgui say

* push that

* .
This commit is contained in:
Kashargul
2025-04-13 03:23:59 +02:00
committed by GitHub
parent 267b05ed6c
commit ce01da8684
111 changed files with 3488 additions and 2678 deletions
+4 -1
View File
@@ -260,7 +260,10 @@
json_data["config"] = list(
"title" = title,
"status" = status,
"interface" = interface,
"interface" = list(
"name" = interface,
"layout" = null, // user.client.prefs.read_preference(/datum/preference/choiced/tgui_layout), // unused
),
//"refreshing" = refreshing,
"refreshing" = FALSE,
"map" = (using_map && using_map.path) ? using_map.path : "Unknown",
+1 -1
View File
@@ -18,7 +18,7 @@ plugins:
- "@typescript-eslint"
settings:
react:
version: '18.2'
version: '19.1'
rules:
## Possible Errors
## ----------------------------------------
-15
View File
@@ -1,15 +0,0 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"jsc": {
"loose": true,
"parser": {
"syntax": "typescript",
"tsx": true
},
"transform": {
"react": {
"runtime": "automatic"
}
}
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -3
View File
@@ -10,8 +10,6 @@ logFilters:
- code: YN0062
level: discard
pnpEnableEsmLoader: false
preferInteractive: true
yarnPath: .yarn/releases/yarn-4.8.1.cjs
yarnPath: .yarn/releases/yarn-4.9.1.cjs
-15
View File
@@ -1,15 +0,0 @@
module.exports = {
roots: ['<rootDir>/packages'],
testMatch: [
'<rootDir>/packages/**/__tests__/*.{js,ts,tsx}',
'<rootDir>/packages/**/*.{spec,test}.{js,ts,tsx}',
],
testPathIgnorePatterns: ['<rootDir>/packages/tgui-bench'],
testEnvironment: 'jsdom',
testRunner: require.resolve('jest-circus/runner'),
transform: {
'^.+\\.(js|cjs|ts|tsx)$': require.resolve('@swc/jest'),
},
moduleFileExtensions: ['js', 'cjs', 'ts', 'tsx', 'json'],
resetMocks: true,
};
+19 -32
View File
@@ -2,64 +2,51 @@
"private": true,
"name": "tgui-workspace",
"version": "6.0.0",
"packageManager": "yarn@4.8.1",
"packageManager": "yarn@4.9.1",
"workspaces": [
"packages/*"
],
"scripts": {
"tgui:analyze": "webpack --analyze",
"tgui:bench": "webpack --env TGUI_BENCH=1 && node packages/tgui-bench/index.js",
"tgui:build": "BROWSERSLIST_IGNORE_OLD_DATA=true webpack",
"tgui:analyze": "rspack --analyze",
"tgui:bench": "rspack --env TGUI_BENCH=1 && node packages/tgui-bench/index.js",
"tgui:build": "rspack build",
"tgui:dev": "node --experimental-modules packages/tgui-dev-server/index.js",
"tgui:lint": "eslint packages --ext .js,.cjs,.ts,.tsx",
"tgui:prettier": "prettier --check .",
"tgui:sonar": "eslint packages -c .eslintrc-sonar.yml",
"tgui:test": "jest --watch",
"tgui:test-simple": "CI=true jest --color",
"tgui:test-ci": "CI=true jest --color --collect-coverage",
"tgui:test": "vitest",
"tgui:test-simple": "CI=true vitest",
"tgui:test-ci": "CI=true vitest",
"tgui:tsc": "tsc",
"tgui:prettier-fix": "prettier --write .",
"tgui:eslint-fix": "eslint --fix packages --ext .js,.cjs,.ts,.jsx,.tsx"
},
"dependencies": {
"@swc/core": "^1.9.1",
"@swc/jest": "^0.2.37",
"@types/jest": "^29.5.14",
"@types/node": "^22.9.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.1",
"@types/webpack-env": "^1.18.5",
"@types/wicg-file-system-access": "^2023.10.5",
"@rspack/cli": "^1.3.4",
"@rspack/core": "^1.3.4",
"@typescript-eslint/eslint-plugin": "^8.28.0",
"@typescript-eslint/parser": "^8.28.0",
"@typescript-eslint/utils": "^8.28.0",
"css-loader": "^7.1.2",
"esbuild-loader": "^4.2.2",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-simple-import-sort": "^12.0.0",
"eslint-plugin-sonarjs": "^0.25.0",
"eslint-plugin-unused-imports": "^4.1.4",
"file-loader": "^6.2.0",
"jest": "^29.7.0",
"jest-circus": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "^25.0.1",
"mini-css-extract-plugin": "^2.9.2",
"jsdom": "^26.0.0",
"prettier": "^3.2.5",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"sass": "^1.80.6",
"sass-embedded": "^1.85.1",
"sass-loader": "^16.0.3",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.6",
"tgui-core": "^2.0.5",
"typescript": "5.8.3",
"typescript-eslint": "^8.28.0",
"url-loader": "^4.1.1",
"webpack": "^5.96.1",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^5.1.4"
"webpack": "^5.99.5"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@types/webpack-env": "^1.18.8",
"@types/wicg-file-system-access": "^2023.10.6",
"@types/ws": "^8.18.1",
"vitest": "^3.1.1"
}
}
-150
View File
@@ -4,136 +4,6 @@
* @license MIT
*/
/**
* Iterates over elements of collection, returning an array of all elements
* iteratee returns truthy for. The predicate is invoked with three
* arguments: (value, index|key, collection).
*
* If collection is 'null' or 'undefined', it will be returned "as is"
* without emitting any errors (which can be useful in some cases).
*/
export const filter = <T>(
collection: T[],
iterateeFn: (input: T, index: number, collection: T[]) => boolean,
): T[] => {
if (collection === null || collection === undefined) {
return collection;
}
if (Array.isArray(collection)) {
const result: T[] = [];
for (let i = 0; i < collection.length; i++) {
const item = collection[i];
if (iterateeFn(item, i, collection)) {
result.push(item);
}
}
return result;
}
throw new Error(`filter() can't iterate on type ${typeof collection}`);
};
type MapFunction = {
<T, U>(
collection: T[],
iterateeFn: (value: T, index: number, collection: T[]) => U,
): U[];
<T, U, K extends string | number>(
collection: Record<K, T>,
iterateeFn: (value: T, index: K, collection: Record<K, T>) => U,
): U[];
};
/**
* Creates an array of values by running each element in collection
* thru an iteratee function. The iteratee is invoked with three
* arguments: (value, index|key, collection).
*
* If collection is 'null' or 'undefined', it will be returned "as is"
* without emitting any errors (which can be useful in some cases).
*/
export const map: MapFunction = (collection, iterateeFn) => {
if (collection === null || collection === undefined) {
return collection;
}
if (Array.isArray(collection)) {
const result: unknown[] = [];
for (let i = 0; i < collection.length; i++) {
result.push(iterateeFn(collection[i], i, collection));
}
return result;
}
if (typeof collection === 'object') {
const result: unknown[] = [];
for (const i in collection) {
if (Object.prototype.hasOwnProperty.call(collection, i)) {
result.push(iterateeFn(collection[i], i, collection));
}
}
return result;
}
throw new Error(`map() can't iterate on type ${typeof collection}`);
};
const COMPARATOR = (objA, objB) => {
const criteriaA = objA.criteria;
const criteriaB = objB.criteria;
const length = criteriaA.length;
for (let i = 0; i < length; i++) {
const a = criteriaA[i];
const b = criteriaB[i];
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
}
return 0;
};
/**
* Creates an array of elements, sorted in ascending order by the results
* of running each element in a collection thru each iteratee.
*
* Iteratees are called with one argument (value).
*/
export const sortBy = <T>(
array: T[],
...iterateeFns: ((input: T) => unknown)[]
): T[] => {
if (!Array.isArray(array)) {
return array;
}
let length = array.length;
// Iterate over the array to collect criteria to sort it by
const mappedArray: {
criteria: unknown[];
value: T;
}[] = [];
for (let i = 0; i < length; i++) {
const value = array[i];
mappedArray.push({
criteria: iterateeFns.map((fn) => fn(value)),
value,
});
}
// Sort criteria using the base comparator
mappedArray.sort(COMPARATOR);
// Unwrap values
const values: T[] = [];
while (length--) {
values[length] = mappedArray[length].value;
}
return values;
};
export const sort = <T>(array: T[]): T[] => sortBy(array);
/**
* Returns a range of numbers from start to end, exclusively.
* For example, range(0, 5) will return [0, 1, 2, 3, 4].
@@ -163,26 +33,6 @@ type ReduceFunction = {
): T;
};
/**
* A fast implementation of reduce.
*/
export const reduce: ReduceFunction = (array, reducerFn, initialValue?) => {
const length = array.length;
let i: number;
let result;
if (initialValue === undefined) {
i = 1;
result = array[0];
} else {
i = 0;
result = initialValue;
}
for (; i < length; i++) {
result = reducerFn(result, array[i], i, array);
}
return result;
};
/**
* Creates a duplicate-free version of an array, using SameValueZero for
* equality comparisons, in which only the first occurrence of each element
+4 -1
View File
@@ -1,5 +1,8 @@
{
"private": true,
"name": "common",
"version": "4.4.1"
"version": "4.3.1",
"devDependencies": {
"vitest": "^3.1.1"
}
}
+7 -5
View File
@@ -1,3 +1,5 @@
import { describe, it } from 'vitest';
import {
type Action,
applyMiddleware,
@@ -30,12 +32,12 @@ const increment = createAction('INCREMENT');
const decrement = createAction('DECREMENT');
describe('Redux implementation tests', () => {
test('createStore works', () => {
it('createStore works', ({ expect }) => {
const store = createStore(counterReducer);
expect(store.getState()).toBe(0);
});
test('createStore with applyMiddleware works', () => {
it('createStore with applyMiddleware works', ({ expect }) => {
const store = createStore(
counterReducer,
applyMiddleware(loggingMiddleware),
@@ -43,7 +45,7 @@ describe('Redux implementation tests', () => {
expect(store.getState()).toBe(0);
});
test('dispatch works', () => {
it('dispatch works', ({ expect }) => {
const store = createStore(counterReducer);
store.dispatch(increment());
expect(store.getState()).toBe(1);
@@ -51,7 +53,7 @@ describe('Redux implementation tests', () => {
expect(store.getState()).toBe(0);
});
test('combineReducers works', () => {
it('combineReducers works', ({ expect }) => {
const rootReducer = combineReducers({
counter: counterReducer,
});
@@ -59,7 +61,7 @@ describe('Redux implementation tests', () => {
expect(store.getState()).toEqual({ counter: 0 });
});
test('createAction works', () => {
it('createAction works', ({ expect }) => {
const incrementAction = increment();
expect(incrementAction).toEqual({ type: 'INCREMENT' });
const decrementAction = decrement();
+5 -3
View File
@@ -4,8 +4,6 @@
"version": "6.0.0",
"dependencies": {
"@fastify/static": "^8.0.2",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.1",
"common": "workspace:*",
"fastify": "^5.1.0",
"lodash": "^4.17.21",
@@ -13,6 +11,10 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tgui": "workspace:*",
"tgui-core": "^2.0.5"
"tgui-core": "^3.0.4"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.2"
}
}
@@ -1,63 +0,0 @@
import React from 'react';
import { createRenderer } from 'tgui/renderer';
import { Button } from 'tgui-core/components';
const render = createRenderer();
export const SingleButton = () => {
const node = <Button>Hello world!</Button>;
render(node);
};
export const SingleButtonWithCallback = () => {
const node = <Button onClick={() => undefined}>Hello world!</Button>;
render(node);
};
export const ListOfButtons = () => {
const nodes: React.JSX.Element[] = [];
for (let i = 0; i < 100; i++) {
const node = <Button key={i}>Hello world! {i}</Button>;
nodes.push(node);
}
render(<div>{nodes}</div>);
};
export const ListOfButtonsWithCallback = () => {
const nodes: React.JSX.Element[] = [];
for (let i = 0; i < 100; i++) {
const node = (
<Button key={i} onClick={() => undefined}>
Hello world! {i}
</Button>
);
nodes.push(node);
}
render(<div>{nodes}</div>);
};
export const ListOfButtonsWithIcons = () => {
const nodes: React.JSX.Element[] = [];
for (let i = 0; i < 100; i++) {
const node = (
<Button key={i} icon={'arrow-left'}>
Hello world! {i}
</Button>
);
nodes.push(node);
}
render(<div>{nodes}</div>);
};
export const ListOfButtonsWithTooltips = () => {
const nodes: React.JSX.Element[] = [];
for (let i = 0; i < 100; i++) {
const node = (
<Button key={i} tooltip={'Hello world!'}>
Hello world! {i}
</Button>
);
nodes.push(node);
}
render(<div>{nodes}</div>);
};
@@ -1,21 +1,10 @@
import { backendUpdate, setGlobalStore } from 'tgui/backend';
import { DisposalBin } from 'tgui/interfaces/DisposalBin';
import { createRenderer } from 'tgui/renderer';
import { render } from 'tgui/renderer';
import { configureStore } from 'tgui/store';
const store = configureStore({ sideEffects: false });
const renderUi = createRenderer((dataJson: string) => {
setGlobalStore(store);
store.dispatch(
backendUpdate({
data: Byond.parseJson(dataJson),
}),
);
return <DisposalBin />;
});
export const data = JSON.stringify({
flush: 0,
full_pressure: 1,
@@ -25,4 +14,14 @@ export const data = JSON.stringify({
isai: 0,
});
export const Default = () => renderUi(data);
export function Default() {
setGlobalStore(store);
store.dispatch(
backendUpdate({
data: Byond.parseJson(data),
}),
);
return render(<DisposalBin />);
}
@@ -1,16 +0,0 @@
import { createRenderer } from 'tgui/renderer';
import { Flex } from 'tgui-core/components';
const render = createRenderer();
export const Default = () => {
const node = (
<Flex align="baseline">
<Flex.Item mr={1}>Text {Math.random()}</Flex.Item>
<Flex.Item grow basis={0}>
Text {Math.random()}
</Flex.Item>
</Flex>
);
render(node);
};
@@ -1,16 +0,0 @@
import { createRenderer } from 'tgui/renderer';
import { Stack } from 'tgui-core/components';
const render = createRenderer();
export const Default = () => {
const node = (
<Stack align="baseline">
<Stack.Item>Text {Math.random()}</Stack.Item>
<Stack.Item grow basis={0}>
Text {Math.random()}
</Stack.Item>
</Stack>
);
render(node);
};
@@ -1,21 +0,0 @@
import React from 'react';
import { createRenderer } from 'tgui/renderer';
import { Box, Tooltip } from 'tgui-core/components';
const render = createRenderer();
export const ListOfTooltips = () => {
const nodes: React.JSX.Element[] = [];
for (let i = 0; i < 100; i++) {
nodes.push(
<Tooltip key={i} content={`This is from tooltip ${i}`} position="bottom">
<Box as="span" backgroundColor="blue" fontSize="48px" m={1}>
Tooltip #{i}
</Box>
</Tooltip>,
);
}
render(<div>{nodes}</div>);
};
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "6.0.0",
"type": "module",
"dependencies": {
"@types/ws": "^8.5.13",
"@rspack/core": "^1.3.4",
"axios": "^1.7.7",
"glob": "^7.2.3",
"source-map": "^0.7.4",
+9 -9
View File
@@ -13,31 +13,31 @@ import { createLogger } from './logging.js';
import { reloadByondCache } from './reloader.js';
import { resolveGlob } from './util.js';
const logger = createLogger('webpack');
const logger = createLogger('rspack');
/**
* @param {any} config
* @return {WebpackCompiler}
* @return {RspackCompiler}
*/
export const createCompiler = async (options) => {
const compiler = new WebpackCompiler();
const compiler = new RspackCompiler();
await compiler.setup(options);
return compiler;
};
class WebpackCompiler {
class RspackCompiler {
async setup(options) {
// Create a require context that is relative to project root
// and retrieve all necessary dependencies.
const requireFromRoot = createRequire(dirname(import.meta.url) + '/../..');
const webpack = await requireFromRoot('webpack');
const createConfig = await requireFromRoot('./webpack.config.js');
const rspack = await requireFromRoot('@rspack/core');
const createConfig = await requireFromRoot('./rspack.config.cjs');
const config = createConfig({}, options);
// Inject the HMR plugin into the config if we're using it
if (options.hot) {
config.plugins.push(new webpack.HotModuleReplacementPlugin());
config.plugins.push(new rspack.HotModuleReplacementPlugin());
}
this.webpack = webpack;
this.rspack = rspack;
this.config = config;
this.bundleDir = config.output.path;
}
@@ -47,7 +47,7 @@ class WebpackCompiler {
// Setup link
const link = setupLink();
// Instantiate the compiler
const compiler = this.webpack.webpack(this.config);
const compiler = this.rspack.rspack(this.config);
// Clear garbage before compiling
compiler.hooks.watchRun.tapPromise('tgui-dev-server', async () => {
const files = await resolveGlob(this.bundleDir, './*.hot-update.*');
@@ -33,9 +33,8 @@ export const ChatPageSettings = (props) => {
<Stack.Item grow>
<Input
fluid
updateOnPropsChange
value={page.name}
onChange={(e, value) =>
onChange={(value) =>
dispatch(
updateChatPage({
pageId: page.id,
+6 -4
View File
@@ -652,10 +652,12 @@ class ChatRenderer {
/* eslint-disable react/no-danger */
reactRoot.render(
<Element {...outputProps}>
<span dangerouslySetInnerHTML={oldHtml} />
</Element>,
childNode,
<>
<Element {...outputProps}>
<span dangerouslySetInnerHTML={oldHtml} />
</Element>
{childNode}
</>,
);
/* eslint-enable react/no-danger */
}
+10 -13
View File
@@ -14,7 +14,7 @@ import { perf } from 'common/perf';
import { combineReducers } from 'common/redux';
import { setGlobalStore } from 'tgui/backend';
import { captureExternalLinks } from 'tgui/links';
import { createRenderer } from 'tgui/renderer';
import { render } from 'tgui/renderer';
import { configureStore } from 'tgui/store';
import { setupGlobalEvents } from 'tgui-core/events';
import { setupHotReloading } from 'tgui-dev-server/link/client.cjs';
@@ -22,12 +22,13 @@ import { setupHotReloading } from 'tgui-dev-server/link/client.cjs';
import { audioMiddleware, audioReducer } from './audio';
import { chatMiddleware, chatReducer } from './chat';
import { gameMiddleware, gameReducer } from './game';
import { Panel } from './Panel';
import { setupPanelFocusHacks } from './panelFocus';
import { pingMiddleware, pingReducer } from './ping';
import { settingsMiddleware, settingsReducer } from './settings';
import { telemetryMiddleware } from './telemetry';
perf.mark('inception', window.performance?.timing?.navigationStart);
perf.mark('inception', window.performance?.timeOrigin);
perf.mark('init');
const store = configureStore({
@@ -50,28 +51,24 @@ const store = configureStore({
},
});
const renderApp = createRenderer(() => {
setGlobalStore(store);
const { Panel } = require('./Panel');
return <Panel />;
});
const setupApp = () => {
function setupApp() {
// Delay setup
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupApp);
return;
}
setGlobalStore(store);
setupGlobalEvents({
ignoreWindowFocus: true,
});
setupPanelFocusHacks();
captureExternalLinks();
// Re-render UI on store updates
store.subscribe(renderApp);
store.subscribe(() => render(<Panel />));
// Dispatch incoming messages as store actions
Byond.subscribe((type, payload) => store.dispatch({ type, payload }));
@@ -104,10 +101,10 @@ const setupApp = () => {
'./telemetry',
],
() => {
renderApp();
render(<Panel />);
},
);
}
};
}
setupApp();
+5 -3
View File
@@ -3,14 +3,16 @@
"name": "tgui-panel",
"version": "6.0.0",
"dependencies": {
"@types/node": "^22.9.0",
"@types/react": "^19.1.0",
"common": "workspace:*",
"dompurify": "^3.2.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tgui": "workspace:*",
"tgui-core": "^2.0.5",
"tgui-core": "^3.0.4",
"tgui-dev-server": "workspace:*"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.2"
}
}
@@ -95,7 +95,7 @@ export const SettingsGeneral = (props) => {
<Input
width={'100%'}
value={fontFamily}
onChange={(e, value) =>
onChange={(value) =>
dispatch(
updateSettings({
fontFamily: value,
@@ -183,11 +183,12 @@ export const SettingsGeneral = (props) => {
<Box inline>
<ColorBox mr={1} color={interleaveColor} />
<Input
expensive
width="5em"
monospace
placeholder="#ffffff"
value={interleaveColor}
onInput={(e, value) =>
onChange={(value) =>
dispatch(
updateSettings({
interleaveColor: value,
@@ -165,11 +165,12 @@ const TextHighlightSetting = (props) => {
<Stack.Item shrink={0}>
<ColorBox mr={1} color={highlightColor} />
<Input
expensive
width="5em"
monospace
placeholder="#ffffff"
value={highlightColor}
onInput={(e, value) =>
onChange={(value) =>
dispatch(
updateHighlightSetting({
id: id,
@@ -181,10 +182,11 @@ const TextHighlightSetting = (props) => {
</Stack.Item>
</Stack>
<TextArea
expensive
height="3em"
value={highlightText}
placeholder="Put words to highlight here. Separate terms with commas, i.e. (term1, term2, term3)"
onChange={(e, value) =>
onChange={(value) =>
dispatch(
updateHighlightSetting({
id: id,
@@ -195,10 +197,11 @@ const TextHighlightSetting = (props) => {
/>
{!!highlightBlacklist && (
<TextArea
expensive
height="3em"
value={blacklistText}
placeholder="Put names of senders you don't want highlighted here. Separate names with commas, i.e. (name1, name2, name3)"
onChange={(e, value) =>
onChange={(value) =>
dispatch(
updateHighlightSetting({
id: id,
+12 -8
View File
@@ -1,3 +1,5 @@
import { beforeEach, describe, it } from 'vitest';
import { ChannelIterator } from './ChannelIterator';
describe('ChannelIterator', () => {
@@ -7,7 +9,7 @@ describe('ChannelIterator', () => {
channelIterator = new ChannelIterator();
});
it('should cycle through channels properly', () => {
it('should cycle through channels properly', ({ expect }) => {
expect(channelIterator.current()).toBe('Say');
expect(channelIterator.next()).toBe('Radio');
expect(channelIterator.next()).toBe('Me');
@@ -18,7 +20,7 @@ describe('ChannelIterator', () => {
expect(channelIterator.next()).toBe('Say'); // Admin is blacklisted so it should be skipped
});
it('should cycle through channels backwards properly', () => {
it('should cycle through channels backwards properly', ({ expect }) => {
expect(channelIterator.current()).toBe('Say');
expect(channelIterator.prev()).toBe('OOC'); // Admin is blacklisted so it should be skipped
expect(channelIterator.prev()).toBe('LOOC');
@@ -29,32 +31,34 @@ describe('ChannelIterator', () => {
expect(channelIterator.prev()).toBe('Say');
});
it('should set a channel properly', () => {
it('should set a channel properly', ({ expect }) => {
channelIterator.set('OOC');
expect(channelIterator.current()).toBe('OOC');
});
it('should return true when current channel is "Say"', () => {
it('should return true when current channel is "Say"', ({ expect }) => {
channelIterator.set('Say');
expect(channelIterator.isSay()).toBe(true);
});
it('should return false when current channel is not "Say"', () => {
it('should return false when current channel is not "Say"', ({ expect }) => {
channelIterator.set('Radio');
expect(channelIterator.isSay()).toBe(false);
});
it('should return true when current channel is visible', () => {
it('should return true when current channel is visible', ({ expect }) => {
channelIterator.set('Say');
expect(channelIterator.isVisible()).toBe(true);
});
it('should return false when current channel is not visible', () => {
it('should return false when current channel is not visible', ({
expect,
}) => {
channelIterator.set('OOC');
expect(channelIterator.isVisible()).toBe(false);
});
it('should not leak a message from a blacklisted channel', () => {
it('should not leak a message from a blacklisted channel', ({ expect }) => {
channelIterator.set('Admin');
expect(channelIterator.next()).toBe('Admin');
});
+7 -5
View File
@@ -1,3 +1,5 @@
import { beforeEach, describe, it } from 'vitest';
import { ChatHistory } from './ChatHistory';
describe('ChatHistory', () => {
@@ -7,12 +9,12 @@ describe('ChatHistory', () => {
chatHistory = new ChatHistory();
});
it('should add a message to the history', () => {
it('should add a message to the history', ({ expect }) => {
chatHistory.add('Hello');
expect(chatHistory.getOlderMessage()).toEqual('Hello');
});
it('should retrieve older and newer messages', () => {
it('should retrieve older and newer messages', ({ expect }) => {
chatHistory.add('Hello');
chatHistory.add('World');
expect(chatHistory.getOlderMessage()).toEqual('World');
@@ -22,7 +24,7 @@ describe('ChatHistory', () => {
expect(chatHistory.getOlderMessage()).toEqual('World');
});
it('should limit the history to 5 messages', () => {
it('should limit the history to 5 messages', ({ expect }) => {
for (let i = 1; i <= 6; i++) {
chatHistory.add(`Message ${i}`);
}
@@ -34,13 +36,13 @@ describe('ChatHistory', () => {
expect(chatHistory.getOlderMessage()).toBeNull();
});
it('should handle temp message correctly', () => {
it('should handle temp message correctly', ({ expect }) => {
chatHistory.saveTemp('Temp message');
expect(chatHistory.getTemp()).toEqual('Temp message');
expect(chatHistory.getTemp()).toBeNull();
});
it('should reset correctly', () => {
it('should reset correctly', ({ expect }) => {
chatHistory.add('Hello');
chatHistory.getOlderMessage();
chatHistory.reset();
+34 -17
View File
@@ -3,6 +3,7 @@ import './styles/main.scss';
import {
type FormEvent,
type KeyboardEvent,
type MouseEvent,
useEffect,
useRef,
useState,
@@ -63,9 +64,11 @@ export function TguiSay() {
const [size, setSize] = useState(WindowSize.Small);
const [maxLength, setMaxLength] = useState(4096);
const [lightMode, setLightMode] = useState(false);
const [position, setPosition] = useState([window.screenX, window.screenY]);
const [value, setValue] = useState('');
const position = useRef([window.screenX, window.screenY]);
const isDragging = useRef(false);
function handleArrowKeys(direction: KEY.PageUp | KEY.PageDown): void {
const chat = chatHistory.current;
const iterator = channelIterator.current;
@@ -110,6 +113,30 @@ export function TguiSay() {
}
}
function handleButtonClick(event: MouseEvent<HTMLButtonElement>): void {
isDragging.current = true;
setTimeout(() => {
// So the button doesn't jump around accidentally
if (isDragging.current) {
dragStartHandler(event.nativeEvent);
}
}, 50);
}
// Prevents the button from changing channels if it's dragged
function handleButtonRelease(): void {
isDragging.current = false;
const currentPosition = [window.screenX, window.screenY];
if (JSON.stringify(position.current) !== JSON.stringify(currentPosition)) {
position.current = currentPosition;
return;
}
handleIncrementChannel();
}
function handleClose(): void {
innerRef.current?.blur();
windowClose(minimumWidth.current, minimumHeight.current, scale.current);
@@ -157,9 +184,6 @@ export function TguiSay() {
}
function handleIncrementChannel(): void {
const xPos = window.screenX;
const yPos = window.screenY;
if (JSON.stringify(position) !== JSON.stringify([xPos, yPos])) return;
const iterator = channelIterator.current;
iterator.next();
@@ -285,19 +309,8 @@ export function TguiSay() {
}
}
function handleButtonDrag(e: React.MouseEvent<Element, MouseEvent>): void {
const xPos = window.screenX;
const yPos = window.screenY;
setPosition([xPos, yPos]);
dragStartHandler(e);
}
function handleOpen(data: ByondOpen): void {
setSize(minimumHeight.current);
setTimeout(() => {
innerRef.current?.focus();
}, 1);
const { channel } = data;
const iterator = channelIterator.current;
// Catches the case where the modal is already open
@@ -312,6 +325,10 @@ export function TguiSay() {
minimumHeight.current,
scale.current,
);
const input = innerRef.current;
setTimeout(() => {
input?.focus();
}, 1);
}
function handleProps(data: ByondProps): void {
@@ -384,8 +401,8 @@ export function TguiSay() {
>
<button
className={`button button-${theme}`}
onClick={handleIncrementChannel}
onMouseDown={handleButtonDrag}
onMouseDown={handleButtonClick}
onMouseUp={handleButtonRelease}
type="button"
>
{buttonContent}
+3
View File
@@ -13,6 +13,9 @@ export function windowOpen(
): void {
setWindowVisibility(true, width, height, scale);
Byond.sendMessage('open', { channel });
Byond.winset('tgui_say.browser', {
focus: true,
});
}
/**
+6 -3
View File
@@ -3,12 +3,15 @@
"name": "tgui-say",
"version": "1.0.0",
"dependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.1",
"common": "workspace:*",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tgui": "workspace:*",
"tgui-core": "^2.0.5"
"tgui-core": "^3.0.4"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.2",
"vitest": "^3.1.1"
}
}
+4
View File
@@ -14,6 +14,10 @@
@each $channel, $color in colors.$channel-map {
$darkened: color.adjust($color, $lightness: -20%, $space: hsl);
.button {
user-select: none;
}
.button-#{$channel} {
border-color: color.adjust($color, $lightness: -10%, $space: hsl);
color: $color;
+14
View File
@@ -0,0 +1,14 @@
import { globalStore } from './backend';
import { IconProvider } from './Icons';
export function App() {
const { getRoutedComponent } = require('./routes');
const Component = getRoutedComponent(globalStore);
return (
<>
<Component />
<IconProvider />
</>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { Suspense, useEffect } from 'react';
import { fetchRetry } from 'tgui-core/http';
import { resolveAsset } from './assets';
import { logger } from './logging';
function loadIconMap() {
fetchRetry(resolveAsset('icon_ref_map.json'))
.then((res) => res.json())
.then((data) => (Byond.iconRefMap = data))
.catch((error) => logger.log(error));
}
function IconMapLoader() {
useEffect(() => {
if (Object.keys(Byond.iconRefMap).length === 0) {
loadIconMap();
}
}, []);
return null;
}
export function IconProvider() {
return (
<Suspense fallback={null}>
<IconMapLoader />
</Suspense>
);
}
+4 -1
View File
@@ -262,7 +262,10 @@ type BackendState<TData> = {
config: {
title: string;
status: number;
interface: string;
interface: {
name: string;
layout: string;
};
refreshing: boolean;
map: string; // Vorestation Add
mapZLevel: number; // Vorestation Add
+12 -10
View File
@@ -1,57 +1,59 @@
import { describe, it } from 'vitest';
// import { getGasColor, getGasFromId, getGasFromPath, getGasLabel } from './constants';
import { getGasColor, getGasFromId, getGasLabel } from './constants';
describe('gas helper functions', () => {
it('should get the proper gas label', () => {
it('should get the proper gas label', ({ expect }) => {
// Testing for alphabetic gas id
const gasId = 'oxygen';
const gasLabel = getGasLabel(gasId);
expect(gasLabel).toBe('O₂');
});
it('should get the proper gas label', () => {
it('should get the proper gas label', ({ expect }) => {
// Testing for underscore gas id
const gasId = 'nitrous_oxide';
const gasLabel = getGasLabel(gasId);
expect(gasLabel).toBe('N₂O');
});
it('should get the proper gas label', () => {
it('should get the proper gas label', ({ expect }) => {
// Testing for wrong capitalization of two word gas
const gasId = 'nitrous oxide';
const gasLabel = getGasLabel(gasId); // This should set to Nitrous Oxide before checking
expect(gasLabel).toBe('N₂O');
});
it('should get the proper gas label with a fallback', () => {
it('should get the proper gas label with a fallback', ({ expect }) => {
const gasId = 'nonexistent';
const gasLabel = getGasLabel(gasId, 'fallback');
expect(gasLabel).toBe('fallback');
});
it('should return none if no gas and no fallback is found', () => {
it('should return none if no gas and no fallback is found', ({ expect }) => {
const gasId = 'nonexistent';
const gasLabel = getGasLabel(gasId);
expect(gasLabel).toBe('None');
});
it('should get the proper gas color', () => {
it('should get the proper gas color', ({ expect }) => {
const gasId = 'nitrous_oxide';
const gasColor = getGasColor(gasId);
expect(gasColor).toBe('red');
});
it('should return a string if no gas is found', () => {
it('should return a string if no gas is found', ({ expect }) => {
const gasId = 'nonexistent';
const gasColor = getGasColor(gasId);
expect(gasColor).toBe('black');
});
it('should return the gas object if found', () => {
it('should return the gas object if found', ({ expect }) => {
const gasId = 'nitrous_oxide';
const gas = getGasFromId(gasId);
@@ -64,7 +66,7 @@ describe('gas helper functions', () => {
});
});
it('should return undefined if no gas is found', () => {
it('should return undefined if no gas is found', ({ expect }) => {
const gasId = 'nonexistent';
const gas = getGasFromId(gasId);
@@ -72,7 +74,7 @@ describe('gas helper functions', () => {
});
/*
it('should return the gas using a path', () => {
it('should return the gas using a path', ({ expect }) => {
const gasPath = '/datum/gas/antinoblium';
const gas = getGasFromPath(gasPath);
-15
View File
@@ -1,15 +0,0 @@
import { fetchRetry } from 'tgui-core/http';
import { resolveAsset } from './assets';
import { logger } from './logging';
export function loadIconRefMap() {
if (Object.keys(Byond.iconRefMap).length > 0) {
return;
}
fetchRetry(resolveAsset('icon_ref_map.json'))
.then((res) => res.json())
.then((data) => (Byond.iconRefMap = data))
.catch((error) => logger.log(error));
}
+17 -22
View File
@@ -8,15 +8,24 @@
import './styles/main.scss';
import './styles/themes/abductor.scss';
import './styles/themes/cardtable.scss';
import './styles/themes/spookyconsole.scss';
import './styles/themes/hackerman.scss';
import './styles/themes/malfunction.scss';
import './styles/themes/neutral.scss';
import './styles/themes/ntos.scss';
import './styles/themes/ntos_cat.scss';
import './styles/themes/ntos_darkmode.scss';
import './styles/themes/ntos_lightmode.scss';
import './styles/themes/ntOS95.scss';
import './styles/themes/ntos_synth.scss';
import './styles/themes/ntos_terminal.scss';
import './styles/themes/ntos_spooky.scss';
import './styles/themes/paper.scss';
import './styles/themes/pda-retro.scss';
import './styles/themes/retro.scss';
import './styles/themes/syndicate.scss';
import './styles/themes/wizard.scss';
import './styles/themes/admin.scss';
import './styles/themes/abstract.scss';
import './styles/themes/bingle.scss';
@@ -25,26 +34,17 @@ import { setupGlobalEvents } from 'tgui-core/events';
import { setupHotKeys } from 'tgui-core/hotkeys';
import { setupHotReloading } from 'tgui-dev-server/link/client.cjs';
import { App } from './App';
import { setGlobalStore } from './backend';
import { loadIconRefMap } from './icons';
import { captureExternalLinks } from './links';
import { createRenderer } from './renderer';
import { render } from './renderer';
import { configureStore } from './store';
perf.mark('inception', window.performance?.timing?.navigationStart);
perf.mark('inception', window.performance?.timeOrigin);
perf.mark('init');
const store = configureStore();
const renderApp = createRenderer(() => {
setGlobalStore(store);
loadIconRefMap();
const { getRoutedComponent } = require('./routes');
const Component = getRoutedComponent(store);
return <Component />;
});
function setupApp() {
// Delay setup
if (document.readyState === 'loading') {
@@ -52,12 +52,13 @@ function setupApp() {
return;
}
setGlobalStore(store);
setupGlobalEvents();
setupHotKeys();
captureExternalLinks();
// Re-render UI on store updates
store.subscribe(renderApp);
store.subscribe(() => render(<App />));
// Dispatch incoming messages as store actions
Byond.subscribe((type, payload) => store.dispatch({ type, payload }));
@@ -65,14 +66,8 @@ function setupApp() {
// Enable hot module reloading
if (module.hot) {
setupHotReloading();
// prettier-ignore
module.hot.accept([
'./components',
'./debug',
'./layouts',
'./routes',
], () => {
renderApp();
module.hot.accept(['./debug', './layouts', './routes', './App'], () => {
render(<App />);
});
}
}
@@ -115,10 +115,10 @@ const NewAccountView = (props) => {
<Section title="Create Account">
<LabeledList>
<LabeledList.Item label="Account Holder">
<Input value={holder} fluid onInput={(e, val) => setHolder(val)} />
<Input value={holder} fluid onChange={(val) => setHolder(val)} />
</LabeledList.Item>
<LabeledList.Item label="Initial Deposit">
<Input value={newMoney} fluid onInput={(e, val) => setMoney(val)} />
<Input value={newMoney} fluid onChange={(val) => setMoney(val)} />
</LabeledList.Item>
</LabeledList>
<Button
@@ -300,7 +300,7 @@ export const AppearanceChangerMarkings = (props) => {
fluid
placeholder={'Search for markings...'}
value={searchText}
onInput={(e, val) => setSearchText(val)}
onChange={(val) => setSearchText(val)}
/>
</Stack.Item>
<Stack.Item>
@@ -39,7 +39,7 @@ export const AppearanceChangerParts = (props: {
fluid
placeholder={'Search for ' + section.toLowerCase() + '...'}
value={searchText}
onInput={(e, val) => setSearchText(val)}
onChange={(val) => setSearchText(val)}
/>
)}
</Stack.Item>
@@ -107,7 +107,7 @@ export const AppearanceChangerHair = (props: {
fluid
placeholder={'Search for ' + section.toLowerCase() + '...'}
value={searchText}
onInput={(e, val) => setSearchText(val)}
onChange={(val) => setSearchText(val)}
/>
</Stack.Item>
<Stack.Item grow>
+2 -2
View File
@@ -130,9 +130,9 @@ const Designs = (props) => {
<Input
fluid
placeholder="Search all designs..."
updateOnPropsChange
expensive
value={searchText}
onChange={(e, val) => setSearchText(val)}
onChange={(val) => setSearchText(val)}
/>
</Stack.Item>
</Stack>
@@ -278,17 +278,9 @@ const CustomWithdrawal = (props: {
<Stack.Item>
<Input
fluid
value={money}
value={money.toString()}
maxLength={10}
onInput={(e, val) => {
const value = parseInt(val, 10);
if (isNaN(value)) {
setMoney(0);
} else {
setMoney(value);
}
}}
onChange={(e, val) => {
onChange={(val) => {
const value = parseInt(val, 10);
if (isNaN(value)) {
setMoney(0);
@@ -479,18 +471,16 @@ const TransferMenu = (props: {
<Input
fluid
maxLength={6}
value={accountNum}
onChange={(e, val) => updateAccountNum(val)}
onInput={(e, val) => updateAccountNum(val)}
value={accountNum.toString()}
onChange={(val) => updateAccountNum(val)}
/>
</LabeledList.Item>
<LabeledList.Item label="Funds To Transfer">
<Input
fluid
maxLength={10}
value={money}
onChange={(e, val) => updateMoney(val)}
onInput={(e, val) => updateMoney(val)}
value={money.toString()}
onChange={(val) => updateMoney(val)}
/>
</LabeledList.Item>
<LabeledList.Item label="Transaction Purpose">
@@ -498,8 +488,7 @@ const TransferMenu = (props: {
fluid
maxLength={20}
value={purpose}
onChange={(e, val) => setPurpose(val)}
onInput={(e, val) => setPurpose(val)}
onChange={(val) => setPurpose(val)}
/>
</LabeledList.Item>
</LabeledList>
@@ -560,17 +549,11 @@ const LoginScreen = (props: { machine_id: string; card: string | null }) => {
<Input
fluid
value={account}
onChange={(e, val) => setAccount(val)}
onInput={(e, val) => setAccount(val)}
onChange={(val) => setAccount(val)}
/>
</LabeledList.Item>
<LabeledList.Item label="PIN">
<Input
fluid
value={pin}
onChange={(e, val) => setPin(val)}
onInput={(e, val) => setPin(val)}
/>
<Input fluid value={pin} onChange={(val) => setPin(val)} />
</LabeledList.Item>
</LabeledList>
<Button
@@ -169,7 +169,7 @@ const BiogeneratorSearch = (props: {
placeholder="Search by item name.."
value={props.searchText}
width="100%"
onInput={(e, value: string) => props.onSearchText(value)}
onChange={(value: string) => props.onSearchText(value)}
/>
</Stack.Item>
<Stack.Item basis="30%">
@@ -155,7 +155,7 @@ export const CameraConsoleContent = (props) => {
fluid
mt={1}
placeholder="Search for a camera"
onInput={(e, value: string) => setSearchText(value)}
onChange={(value: string) => setSearchText(value)}
/>
</Stack.Item>
<Stack.Item>
@@ -81,7 +81,7 @@ const CasinoPrizeDispenserSearch = (props: {
<Input
placeholder="Search by item name.."
width="100%"
onInput={(_e, value) => props.onSearchText(value)}
onChange={(value) => props.onSearchText(value)}
/>
</Stack.Item>
<Stack.Item basis="30%">
@@ -96,7 +96,7 @@ export const CheckboxInput = (props) => {
<Input
fluid
value={searchQuery}
onInput={(e, value: string) => setSearchQuery(value)}
onChange={(value: string) => setSearchQuery(value)}
/>
</Stack.Item>
</Stack>
@@ -109,7 +109,7 @@ export const AnalyzerSearchList = (props: {
fluid
value={searchText}
placeholder={'Search for ' + title + '...'}
onInput={(e, value: string) => onSearchText(value)}
onChange={(value: string) => onSearchText(value)}
/>
</Stack.Item>
<Divider />
@@ -243,12 +243,10 @@ export const ColorMateMatrix = (props) => {
<LabeledList>
<LabeledList.Item label="Config">
<Input
updateOnPropsChange
expensive
fluid
value={Object.values(matrixcolors).toString()}
onChange={(e, value: string) =>
act('set_matrix_string', { value })
}
onChange={(value: string) => act('set_matrix_string', { value })}
/>
</LabeledList.Item>
</LabeledList>
@@ -71,7 +71,7 @@ export const CommunicatorPhoneTab = (props) => {
<Input
fluid
value={targetAddress}
onInput={(e, val) => act('write_target_address', { val: val })}
onChange={(val) => act('write_target_address', { val: val })}
/>
</Stack.Item>
<Stack.Item>
@@ -79,7 +79,7 @@ export const CrewMonitorCrew = (props: { crew: crewmember[] }) => {
fluid
value={nameSearch}
placeholder="Search for Name..."
onChange={(e, val) => setNameSearch(val)}
onChange={(val) => setNameSearch(val)}
/>
</Stack.Item>
<Stack.Item>
@@ -184,8 +184,9 @@ export const NarrationInput = (props) => {
<Stack>
<Stack.Item width="85%">
<TextArea
expensive
height={'18rem'}
onChange={(e, val) => setNarration(val)}
onChange={(val) => setNarration(val)}
value={narration || ''}
/>
</Stack.Item>
@@ -110,7 +110,7 @@ export const PartLists = (props: {
fluid
placeholder="Search for..."
value={searchText}
onInput={(e, v) => setSearchText(v)}
onChange={(v) => setSearchText(v)}
/>
</Stack.Item>
</Stack>
@@ -21,7 +21,7 @@ export const GeneralRecordsList = (props) => {
<Input
fluid
placeholder="Search by Name, DNA, or ID"
onInput={(e, value: string) => setSearchText(value)}
onChange={(value: string) => setSearchText(value)}
/>
<Box mt="0.5rem">
{records.map((record, i) => (
@@ -168,18 +168,16 @@ export const IdentificationComputerAccessModification = (props: {
<LabeledList>
<LabeledList.Item label="Registered Name">
<Input
updateOnPropsChange
value={target_owner!}
fluid
onInput={(e, val) => act('reg', { reg: val })}
onChange={(val) => act('reg', { reg: val })}
/>
</LabeledList.Item>
<LabeledList.Item label="Account Number">
<Input
updateOnPropsChange
value={account_number!}
value={account_number?.toString()}
fluid
onInput={(e, val) => act('account', { account: val })}
onChange={(val) => act('account', { account: val })}
/>
</LabeledList.Item>
<LabeledList.Item label="Dismissals">
+4 -4
View File
@@ -252,14 +252,14 @@ export const Jukebox = (props) => {
<Input
width="100%"
value={newTitle}
onChange={(e, val: string) => setNewTitle(val)}
onChange={(val: string) => setNewTitle(val)}
/>
</LabeledList.Item>
<LabeledList.Item label="URL">
<Input
width="100%"
value={newUrl}
onChange={(e, val: string) => setNewUrl(val)}
onChange={(val: string) => setNewUrl(val)}
/>
</LabeledList.Item>
<LabeledList.Item label="Playtime">
@@ -276,7 +276,7 @@ export const Jukebox = (props) => {
<Input
width="100%"
value={newArtist}
onChange={(e, val: string) => setNewArtist(val)}
onChange={(val: string) => setNewArtist(val)}
/>
</LabeledList.Item>
<LabeledList.Item label="Genre">
@@ -286,7 +286,7 @@ export const Jukebox = (props) => {
<Input
width="100%"
value={newGenre}
onChange={(e, val: string) => setNewGenre(val)}
onChange={(val: string) => setNewGenre(val)}
/>
) : (
<Box>{newGenre}</Box>
+9 -9
View File
@@ -294,10 +294,10 @@ export const LawManagerLaws = (props: {
<Table.Cell collapsing>Zero</Table.Cell>
<Table.Cell>
<Input
updateOnPropsChange
expensive
value={zeroth_law}
fluid
onChange={(e, val: string) =>
onChange={(val: string) =>
act('change_zeroth_law', { val: val })
}
/>
@@ -316,10 +316,10 @@ export const LawManagerLaws = (props: {
<Table.Cell collapsing>Ion</Table.Cell>
<Table.Cell>
<Input
updateOnPropsChange
expensive
value={ion_law}
fluid
onChange={(e, val: string) =>
onChange={(val: string) =>
act('change_ion_law', { val: val })
}
/>
@@ -335,10 +335,10 @@ export const LawManagerLaws = (props: {
<Table.Cell>Inherent</Table.Cell>
<Table.Cell>
<Input
updateOnPropsChange
expensive
value={inherent_law}
fluid
onChange={(e, val: string) =>
onChange={(val: string) =>
act('change_inherent_law', { val: val })
}
/>
@@ -354,10 +354,10 @@ export const LawManagerLaws = (props: {
<Table.Cell>Supplied</Table.Cell>
<Table.Cell>
<Input
updateOnPropsChange
expensive
value={supplied_law}
fluid
onChange={(e, val: string) =>
onChange={(val: string) =>
act('change_supplied_law', { val: val })
}
/>
@@ -496,7 +496,7 @@ export const LawManagerLawSets = (props: {
fluid
value={searchLawName}
placeholder="Search for laws..."
onInput={(e, value: string) => onSearchLawName(value)}
onChange={(value: string) => onSearchLawName(value)}
/>
{law_sets.length
? prepareSearch(law_sets, searchLawName).map((laws) => (
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useState } from 'react';
import { useBackend } from 'tgui/backend';
import { Window } from 'tgui/layouts';
import { Autofocus, Button, Input, Section, Stack } from 'tgui-core/components';
@@ -31,7 +31,7 @@ export const ListInputModal = (props) => {
const [searchQuery, setSearchQuery] = useState('');
// User presses up or down on keyboard
// Simulates clicking an item
const inputRef = useRef<HTMLDivElement>(null);
const onArrowKey = (key: KEY) => {
const len = filteredItems.length - 1;
if (key === KEY.Down) {
@@ -160,11 +160,17 @@ export const ListInputModal = (props) => {
/>
</Stack.Item>
{searchBarVisible && (
<SearchBar
filteredItems={filteredItems}
onSearch={onSearch}
searchQuery={searchQuery}
selected={selected}
<Input
autoFocus
autoSelect
fluid
expensive
onEnter={() => {
act('submit', { entry: filteredItems[selected] });
}}
onChange={onSearch}
placeholder="Search..."
value={searchQuery}
/>
)}
<Stack.Item>
@@ -200,6 +206,7 @@ const ListDisplay = (props) => {
{filteredItems.map((item, index) => {
return (
<Button
className="candystripe"
color="transparent"
fluid
id={index}
@@ -225,27 +232,3 @@ const ListDisplay = (props) => {
</Section>
);
};
/**
* Renders a search bar input.
* Closing the bar defaults input to an empty string.
*/
const SearchBar = (props) => {
const { act } = useBackend<ListInputData>();
const { filteredItems, onSearch, searchQuery, selected } = props;
return (
<Input
autoFocus
autoSelect
fluid
onEnter={(event) => {
event.preventDefault();
act('submit', { entry: filteredItems[selected] });
}}
onInput={(_, value) => onSearch(value)}
placeholder="Search..."
value={searchQuery}
/>
);
};
@@ -37,7 +37,7 @@ export const MaterialStack = (props) => {
fluid
placeholder="Search for recipe..."
value={searchText}
onInput={(e, val) => setSearchText(val)}
onChange={(val) => setSearchText(val)}
/>
</Stack.Item>
<Stack.Item>
@@ -11,7 +11,7 @@ export const MedicalRecordsList = (props) => {
<Input
fluid
placeholder="Search by Name, DNA, or ID"
onChange={(e, value: string) => act('search', { t1: value })}
onChange={(value: string) => act('search', { t1: value })}
/>
<Box mt="0.5rem">
{records.map((record, i) => (
@@ -23,9 +23,10 @@ export const MessageMonitorLogin = (props) => {
<Box color="label" my="1rem">
Decryption Key:
<Input
expensive
placeholder="Decryption Key"
ml="0.5rem"
onChange={(e, val) => act('auth', { key: val })}
onChange={(val) => act('auth', { key: val })}
/>
</Box>
{!!isMalfAI && (
@@ -160,15 +160,17 @@ export const MessageMonitorAdmin = (props) => {
<LabeledList.Item label="Sender">
<Input
fluid
expensive
value={customsender}
onChange={(e, val) => act('set_sender', { val: val })}
onChange={(val) => act('set_sender', { val: val })}
/>
</LabeledList.Item>
<LabeledList.Item label="Sender's Job">
<Input
fluid
expensive
value={customjob}
onChange={(e, val) => act('set_sender_job', { val: val })}
onChange={(val) => act('set_sender_job', { val: val })}
/>
</LabeledList.Item>
<LabeledList.Item label="Recipient">
@@ -189,8 +191,9 @@ export const MessageMonitorAdmin = (props) => {
<Input
fluid
mb={0.5}
expensive
value={custommessage}
onChange={(e, val: string) => act('set_message', { val: val })}
onChange={(val: string) => act('set_message', { val: val })}
/>
</LabeledList.Item>
</LabeledList>
@@ -137,7 +137,7 @@ const MiningVendorSearch = (props: {
placeholder="Search by item name.."
value={props.searchText}
width="100%"
onInput={(_e, value) => props.onSearchText(value)}
onChange={(value) => props.onSearchText(value)}
/>
</Stack.Item>
<Stack.Item basis="30%">
+5 -3
View File
@@ -190,7 +190,7 @@ const GeneralMobSettings = (props: {
<Input
fluid
value={data.path_name}
onChange={(e, val: string) => props.onName(val)}
onChange={(val: string) => props.onName(val)}
/>
</LabeledList.Item>
<LabeledList.Item label="Mob Path">
@@ -364,7 +364,8 @@ const GeneralMobSettings = (props: {
<br />
<TextArea
height={'18rem'}
onChange={(e, val: string) => props.onDesc(val)}
expensive
onChange={(val: string) => props.onDesc(val)}
value={data.desc}
/>
</Stack.Item>
@@ -373,8 +374,9 @@ const GeneralMobSettings = (props: {
<br />
<TextArea
height={'18rem'}
expensive
value={data.flavor_text}
onChange={(e, val: string) => props.onFlavor(val)}
onChange={(val: string) => props.onFlavor(val)}
/>
</Stack.Item>
</Stack>
@@ -142,7 +142,7 @@ const AccessSection = (props: {
fluid
value={searchText}
placeholder="Search for access..."
onInput={(e, value: string) => onSearchText(value)}
onChange={(value: string) => onSearchText(value)}
/>
<Divider />
<Stack>
@@ -242,7 +242,7 @@ const ComponentSection = (props: {
fluid
value={searchText}
placeholder="Search for components..."
onInput={(e, value: string) => onSearchText(value)}
onChange={(value: string) => onSearchText(value)}
/>
<Divider />
<Stack>
@@ -160,7 +160,7 @@ const SelectionField = (props: {
fluid
value={searchText}
placeholder="Search for modules..."
onInput={(e, value: string) => onSearchText(value)}
onChange={(value: string) => onSearchText(value)}
/>
<Divider />
<Stack>
@@ -36,7 +36,7 @@ export const ModifyRobotPKA = (props: { target: Target }) => {
fluid
value={searchModkitText}
placeholder="Search for modkits..."
onInput={(e, value: string) => setSearchModkitText(value)}
onChange={(value: string) => setSearchModkitText(value)}
/>
<Divider />
{prepareSearch(target.pka.modkits, searchModkitText).map(
@@ -82,7 +82,7 @@ export const ModifyRobotPKA = (props: { target: Target }) => {
fluid
value={searchInstalledtext}
placeholder="Search for modkits..."
onInput={(e, value: string) => setSearchInstalledtext(value)}
onChange={(value: string) => setSearchInstalledtext(value)}
/>
<Divider />
{prepareSearch(
@@ -89,7 +89,7 @@ const RadioSection = (props: {
fluid
value={searchText}
placeholder="Search for channels..."
onInput={(e, value: string) => onSearchText(value)}
onChange={(value: string) => onSearchText(value)}
/>
<Divider />
<Stack>
@@ -122,7 +122,7 @@ const UpgradeSection = (props: {
fluid
value={searchText}
placeholder="Search for upgrades..."
onInput={(e, value: string) => onSearchText(value)}
onChange={(value: string) => onSearchText(value)}
/>
<Divider />
<Stack>
@@ -178,7 +178,7 @@ export const ModifyRobot = (props) => {
<Input
width="200px"
value={robotName}
onChange={(e, value) => setRobotName(value)}
onChange={(value) => setRobotName(value)}
/>
</Stack.Item>
<Stack.Item>
@@ -25,9 +25,9 @@ export const NewscasterNewChannel = (props: { setScreen: Function }) => {
<LabeledList.Item label="Channel Name">
<Input
fluid
updateOnPropsChange
expensive
value={decodeHtmlEntities(channel_name)}
onInput={(e, val: string) => act('set_channel_name', { val: val })}
onChange={(val: string) => act('set_channel_name', { val: val })}
/>
</LabeledList.Item>
<LabeledList.Item label="Channel Author" color="good">
@@ -31,17 +31,17 @@ export const NewscasterNewWanted = (props: { setScreen: Function }) => {
<LabeledList.Item label="Criminal Name">
<Input
fluid
updateOnPropsChange
expensive
value={decodeHtmlEntities(channel_name)}
onInput={(e, val) => act('set_channel_name', { val: val })}
onChange={(val) => act('set_channel_name', { val: val })}
/>
</LabeledList.Item>
<LabeledList.Item label="Description">
<Input
fluid
updateOnPropsChange
expensive
value={decodeHtmlEntities(msg)}
onInput={(e, val) => act('set_wanted_desc', { val: val })}
onChange={(val) => act('set_wanted_desc', { val: val })}
/>
</LabeledList.Item>
<LabeledList.Item label="Attach Photo">
@@ -397,7 +397,7 @@ const NtosEmailClientNewMessage = (props) => {
<Input
fluid
value={msg_title!}
onInput={(e, val: string) => act('edit_title', { val: val })}
onChange={(val: string) => act('edit_title', { val: val })}
/>
</LabeledList.Item>
<LabeledList.Item label="Recipient" verticalAlign="top">
@@ -406,9 +406,7 @@ const NtosEmailClientNewMessage = (props) => {
<Input
fluid
value={msg_recipient!}
onInput={(e, val: string) =>
act('edit_recipient', { val: val })
}
onChange={(val: string) => act('edit_recipient', { val: val })}
/>
</Stack.Item>
<Stack.Item>
@@ -498,14 +496,14 @@ const NtosEmailClientLogin = (props) => {
<Input
fluid
value={stored_login!}
onInput={(e, val: string) => act('edit_login', { val: val })}
onChange={(val: string) => act('edit_login', { val: val })}
/>
</LabeledList.Item>
<LabeledList.Item label="Password">
<Input
fluid
value={stored_password!}
onInput={(e, val: string) => act('edit_password', { val: val })}
onChange={(val: string) => act('edit_password', { val: val })}
/>
</LabeledList.Item>
</LabeledList>
@@ -121,7 +121,7 @@ export const NtosNetChat = (props) => {
fluid
selfClear
mt={1}
onEnter={(e, value) =>
onEnter={(value) =>
act('PRG_speak', {
message: value,
})
@@ -9,32 +9,37 @@ import {
Stack,
} from 'tgui-core/components';
import { isEscape, KEY } from 'tgui-core/keys';
import type { BooleanLike } from 'tgui-core/react';
import { InputButtons } from './common/InputButtons';
import { Loader } from './common/Loader';
type NumberInputData = {
type Data = {
init_value: number;
large_buttons: boolean;
max_value: number | null;
large_buttons: BooleanLike;
max_value: number;
message: string;
min_value: number | null;
min_value: number;
round_value: BooleanLike;
timeout: number;
title: string;
round_value: boolean;
};
export const NumberInputModal = (props) => {
const { act, data } = useBackend<NumberInputData>();
const { init_value, large_buttons, message = '', timeout, title } = data;
const [input, setInput] = useState(init_value);
export function NumberInputModal(props) {
const { act, data } = useBackend<Data>();
const {
init_value,
large_buttons,
max_value = 10000,
message = '',
min_value = 0,
round_value,
timeout,
title,
} = data;
const setValue = (value: number) => {
if (value === input) {
return;
}
setInput(value);
};
const [value, setValue] = useState(init_value);
const [isValid, setIsValid] = useState(true);
// Dynamically changes the window height based on the message.
const windowHeight =
@@ -42,88 +47,89 @@ export const NumberInputModal = (props) => {
(message.length > 30 ? Math.ceil(message.length / 3) : 0) +
(message.length && large_buttons ? 5 : 0);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === KEY.Enter && isValid) {
act('submit', { entry: value });
}
if (isEscape(event.key)) {
act('cancel');
}
}
return (
<Window title={title} width={270} height={windowHeight}>
{timeout && <Loader value={timeout} />}
<Window.Content
onKeyDown={(event) => {
if (event.key === KEY.Enter) {
act('submit', { entry: input });
}
if (isEscape(event.key)) {
act('cancel');
}
}}
>
<Window.Content onKeyDown={handleKeyDown}>
<Section fill>
<Stack fill vertical>
<Stack.Item grow>
<Box color="label">{message}</Box>
</Stack.Item>
<Stack.Item>
<InputArea
input={input}
onClick={setValue}
onChange={setValue}
onBlur={setValue}
/>
<Stack fill>
<Stack.Item>
<Button
disabled={value === min_value}
icon="angle-double-left"
onClick={() => setValue(min_value ?? 0)}
tooltip={min_value ? `Min (${min_value})` : 'Min'}
/>
</Stack.Item>
<Stack.Item>
<Button
icon="angle-down"
disabled={value <= min_value}
onClick={() => setValue((value) => value - 1)}
/>
</Stack.Item>
<Stack.Item grow>
<RestrictedInput
autoFocus
autoSelect
fluid
allowFloats={!round_value}
minValue={min_value}
maxValue={max_value}
onChange={setValue}
onValidationChange={setIsValid}
value={value}
/>
</Stack.Item>
<Stack.Item>
<Button
icon="angle-up"
disabled={value >= max_value}
onClick={() => setValue((value) => value + 1)}
/>
</Stack.Item>
<Stack.Item>
<Button
disabled={value === max_value}
icon="angle-double-right"
onClick={() => setValue(max_value ?? 10000)}
tooltip={max_value ? `Max (${max_value})` : 'Max'}
/>
</Stack.Item>
<Stack.Item>
<Button
disabled={value === init_value}
icon="redo"
onClick={() => setValue(init_value ?? 0)}
tooltip={init_value ? `Reset (${init_value})` : 'Reset'}
/>
</Stack.Item>
</Stack>
</Stack.Item>
<Stack.Item>
<InputButtons input={input} />
<InputButtons input={value} disabled={!isValid} />
</Stack.Item>
</Stack>
</Section>
</Window.Content>
</Window>
);
};
/** Gets the user input and invalidates if there's a constraint. */
const InputArea = (props) => {
const { act, data } = useBackend<NumberInputData>();
const { min_value, max_value, init_value, round_value } = data;
const { input, onClick, onChange, onBlur } = props;
return (
<Stack fill>
<Stack.Item>
<Button
disabled={input === min_value || min_value === -Infinity}
icon={min_value === -Infinity ? 'infinity' : 'angle-double-left'}
onClick={() => onClick(min_value)}
tooltip={min_value ? `Min (${min_value})` : 'Min'}
/>
</Stack.Item>
<Stack.Item grow>
<RestrictedInput
autoFocus
autoSelect
fluid
allowFloats={!round_value}
minValue={min_value}
maxValue={max_value}
onChange={(_, value) => onChange(value)}
onBlur={(_, value) => onBlur(value)}
onEnter={(_, value) => act('submit', { entry: value })}
value={input}
/>
</Stack.Item>
<Stack.Item>
<Button
disabled={input === max_value || max_value === Infinity}
icon={max_value === Infinity ? 'infinity' : 'angle-double-right'}
onClick={() => onClick(max_value)}
tooltip={max_value ? `Max (${max_value})` : 'Max'}
/>
</Stack.Item>
<Stack.Item>
<Button
disabled={input === init_value}
icon="redo"
onClick={() => onClick(init_value)}
tooltip={init_value ? `Reset (${init_value})` : 'Reset'}
/>
</Stack.Item>
</Stack>
);
};
}
@@ -142,7 +142,7 @@ export const PersonalCrafting = (props) => {
value={searchText}
mb={1}
placeholder="Search for recipes..."
onInput={(e, value: string) => setSearchText(value)}
onChange={(value: string) => setSearchText(value)}
/>
<Stack>
<Stack.Item>
@@ -132,12 +132,7 @@ export const GamePreferencesPage = (props) => {
)}
<Box position="absolute" right={4} top={4} style={{ zIndex: '100' }}>
{searchVisible && (
<Input
width={16}
value={search}
onInput={(e, val) => setSearch(val)}
onChange={(e, val) => setSearch(val)}
/>
<Input width={16} value={search} onChange={(val) => setSearch(val)} />
)}
<Button
selected={searchVisible}
@@ -265,7 +265,7 @@ export const FeatureShortTextInput = (
width="100%"
value={props.value}
maxLength={props.serverData.maximum_length}
onChange={(_, value) => props.handleSetValue(value)}
onChange={(value) => props.handleSetValue(value)}
/>
);
};
@@ -38,7 +38,7 @@ export const WikiSearchList = (props: {
fluid
value={searchText}
placeholder={'Search for ' + title + '...'}
onInput={(e, value: string) => onSearchText(value)}
onChange={(value: string) => onSearchText(value)}
/>
</Stack.Item>
<Divider />
@@ -9,7 +9,7 @@ export const LayerSection = (props) => {
const { category: rootCategoryIndex, piping_layer, pipe_layers } = data;
const previews = data.preview_rows.flatMap((row) => row.previews);
return (
<Section fill width={7.5}>
<Section fill width="100px">
{rootCategoryIndex === 0 && (
<Stack vertical mb={1}>
{Object.keys(pipe_layers).map((layer) => (
@@ -28,7 +28,7 @@ export const LayerSection = (props) => {
))}
</Stack>
)}
<Box width="120px">
<Stack wrap mt="30px">
{previews.map((preview) => (
<Button
ml={0}
@@ -58,7 +58,7 @@ export const LayerSection = (props) => {
/>
</Button>
))}
</Box>
</Stack>
</Section>
);
};
@@ -198,10 +198,10 @@ const BuildTab = (props: {
>
<Input
fluid
updateOnPropsChange
expensive
placeholder="Search for..."
value={data.search}
onInput={(e, v: string) => act('search', { search: v })}
onChange={(v: string) => act('search', { search: v })}
mb={1}
/>
<Divider />
@@ -33,10 +33,10 @@ export const DesignList = (props) => {
>
<Input
fluid
updateOnPropsChange
expensive
placeholder="Search for..."
value={data.search}
onInput={(e, v) => act('search', { search: v })}
onChange={(v) => act('search', { search: v })}
mb={1}
/>
<Divider />
@@ -41,10 +41,9 @@ export const IconSection = (props: {
<Stack.Item grow>
<Tooltip content="Adjust your name">
<Input
updateOnPropsChange
fluid
value={robotName}
onChange={(e, value) => {
onChange={(value) => {
act('rename', { value });
setRobotName(value);
}}
@@ -26,7 +26,7 @@ export const ModuleSection = (props: {
fluid
value={searchText}
placeholder="Search for modules..."
onInput={(e, value: string) => setSearchText(value)}
onChange={(value: string) => setSearchText(value)}
/>
</Stack.Item>
<Stack.Item>
@@ -64,7 +64,7 @@ export const SpriteSection = (props: {
fluid
value={searchText}
placeholder="Search for sprites..."
onInput={(e, value: string) => setSearchText(value)}
onChange={(value: string) => setSearchText(value)}
/>
</Stack.Item>
<Stack.Item>
@@ -208,7 +208,7 @@ const Status = (props) => {
width="5em"
monospace
value={light_color}
onChange={(e, value: string) => act('set_light_col', { value })}
onChange={(value: string) => act('set_light_col', { value })}
/>
</Box>
</LabeledList.Item>
@@ -11,7 +11,7 @@ export const SecurityRecordsList = (props) => {
<Input
fluid
placeholder="Search by Name, DNA, or ID"
onChange={(e, value: string) => act('search', { t1: value })}
onChange={(value: string) => act('search', { t1: value })}
/>
<Box mt="0.5rem">
{records!.map((record, i) => (
+1 -1
View File
@@ -115,7 +115,7 @@ const SheetSearch = (props: {
placeholder="Search by item name.."
value={searchText}
width="100%"
onInput={(_e, value) => onSearchText(value)}
onChange={(value) => onSearchText(value)}
/>
</Stack.Item>
<Stack.Item basis="30%">
@@ -52,7 +52,7 @@ export const SupplyConsoleMenuOrder = (props) => {
fluid
placeholder={'Search for category...'}
value={searchCategory}
onInput={(e, val) => setSearchCategory(val)}
onChange={(val) => setSearchCategory(val)}
/>
<Divider />
<Section scrollable fill>
@@ -75,7 +75,7 @@ export const SupplyConsoleMenuOrder = (props) => {
fluid
placeholder={'Search for pack...'}
value={searchCategory}
onInput={(e, val) => setSearchContent(val)}
onChange={(val) => setSearchContent(val)}
/>
<Divider />
<Section scrollable fill>
@@ -1,8 +1,9 @@
import { type KeyboardEvent, useState } from 'react';
import { useState } from 'react';
import { useBackend } from 'tgui/backend';
import { Window } from 'tgui/layouts';
import { Box, Section, Stack, TextArea } from 'tgui-core/components';
import { isEscape, KEY } from 'tgui-core/keys';
import { isEscape } from 'tgui-core/keys';
import { KEY } from 'tgui-core/keys';
import { InputButtons } from './common/InputButtons';
import { Loader } from './common/Loader';
@@ -22,7 +23,7 @@ export const sanitizeMultiline = (toSanitize: string) => {
};
export const removeAllSkiplines = (toSanitize: string) => {
return toSanitize.replace(/[\r\n]+/, ' ');
return toSanitize.replace(/[\r\n]+/, '');
};
export const TextInputModal = (props) => {
@@ -38,6 +39,7 @@ export const TextInputModal = (props) => {
} = data;
const [input, setInput] = useState(placeholder || '');
const onType = (value: string) => {
if (value === input) {
return;
@@ -56,34 +58,41 @@ export const TextInputModal = (props) => {
(visualMultiline ? 75 : 0) +
(message.length && large_buttons ? 5 : 0);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === KEY.Enter && (!visualMultiline || !event.shiftKey)) {
act('submit', { entry: input });
}
if (isEscape(event.key)) {
act('cancel');
}
}
return (
<Window title={title} width={325} height={windowHeight}>
{timeout && <Loader value={timeout} />}
<Window.Content
onKeyDown={(event) => {
if (
event.key === KEY.Enter &&
(!visualMultiline || !event.shiftKey)
) {
act('submit', { entry: input });
}
if (isEscape(event.key)) {
act('cancel');
}
}}
>
<Window.Content onKeyDown={handleKeyDown}>
<Section fill>
<Stack fill vertical>
<Stack.Item>
<Box color="label">{message}</Box>
</Stack.Item>
<Stack.Item grow>
<InputArea key={title} input={input} onType={onType} />
<TextArea
autoFocus
autoSelect
fluid
userMarkup={{ u: '_', i: '|', b: '+' }}
height={multiline || input.length >= 30 ? '100%' : '1.8rem'}
maxLength={max_length}
onEscape={() => act('cancel')}
onChange={onType}
placeholder="Type something..."
value={input}
/>
</Stack.Item>
<Stack.Item>
<InputButtons
input={input}
message={`${input.length}/${max_length}`}
message={`${input.length}/${max_length || '∞'}`}
/>
</Stack.Item>
</Stack>
@@ -92,37 +101,3 @@ export const TextInputModal = (props) => {
</Window>
);
};
/** Gets the user input and invalidates if there's a constraint. */
const InputArea = (props: {
input: string;
onType: (value: string) => void;
}) => {
const { act, data } = useBackend<TextInputData>();
const { max_length, multiline } = data;
const { input, onType } = props;
const visualMultiline = multiline || input.length >= 30;
return (
<TextArea
autoFocus
userMarkup={{ u: '_', i: '|', b: '+' }}
autoSelect
height={multiline || input.length >= 30 ? '100%' : '1.8rem'}
maxLength={max_length}
onEscape={() => act('cancel')}
onEnter={(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (visualMultiline && event.shiftKey) {
return;
}
event.preventDefault();
act('submit', { entry: input });
}}
onChange={(_, value) => onType(value)}
onInput={(_, value) => onType(value)}
placeholder="Type something..."
value={input}
/>
);
};
@@ -56,7 +56,7 @@ export const GenericUplink = (props: {
<Input
autoFocus
value={searchText}
onInput={(e, value: string) => setSearchText(value)}
onChange={(value: string) => setSearchText(value)}
mx={1}
/>
</Stack.Item>
+1 -1
View File
@@ -144,7 +144,7 @@ export const VendingProducts = (props: {
<Input
fluid
placeholder="Search for products..."
onInput={(e, value: string) => props.onSearch(value)}
onChange={(value: string) => props.onSearch(value)}
/>
</Table.Cell>
</Table>
@@ -131,7 +131,7 @@ export const ComplexModal = (props) => {
my="0.5rem"
autoFocus
autoSelect
onChange={(_e, val) => {
onChange={(val) => {
curValue = val;
}}
/>
@@ -7,20 +7,41 @@ type InputButtonsData = {
};
type InputButtonsProps = {
input: string | number | string[];
message?: string;
};
input: string | number | string[] | [string, number][];
} & Partial<{
on_submit: () => void;
on_cancel: () => void;
message: string;
/** Disables the submit button */
disabled: boolean;
}>;
export const InputButtons = (props: InputButtonsProps) => {
const { act, data } = useBackend<InputButtonsData>();
const { large_buttons, swapped_buttons } = data;
const { input, message } = props;
const { input, message, on_submit, on_cancel, disabled } = props;
let on_submit_actual = on_submit;
if (!on_submit_actual) {
on_submit_actual = () => {
act('submit', { entry: input });
};
}
let on_cancel_actual = on_cancel;
if (!on_cancel_actual) {
on_cancel_actual = () => {
act('cancel');
};
}
const submitButton = (
<Button
color="good"
disabled={disabled}
fluid={!!large_buttons}
height={!!large_buttons && 2}
onClick={() => act('submit', { entry: input })}
onClick={on_submit_actual}
m={0.5}
pl={2}
pr={2}
@@ -37,7 +58,7 @@ export const InputButtons = (props: InputButtonsProps) => {
color="bad"
fluid={!!large_buttons}
height={!!large_buttons && 2}
onClick={() => act('cancel')}
onClick={on_cancel_actual}
m={0.5}
pl={2}
pr={2}
+17 -11
View File
@@ -1,3 +1,5 @@
import { afterEach, beforeEach, describe, it, vi } from 'vitest';
import { captureExternalLinks } from './links';
describe('captureExternalLinks', () => {
@@ -5,7 +7,7 @@ describe('captureExternalLinks', () => {
let clickHandler;
beforeEach(() => {
addEventListenerSpy = jest.spyOn(document, 'addEventListener');
addEventListenerSpy = vi.spyOn(document, 'addEventListener');
captureExternalLinks();
clickHandler = addEventListenerSpy.mock.calls[0][1];
});
@@ -14,24 +16,26 @@ describe('captureExternalLinks', () => {
addEventListenerSpy.mockRestore();
});
it('should subscribe to document clicks', () => {
it('should subscribe to document clicks', ({ expect }) => {
expect(addEventListenerSpy).toHaveBeenCalledWith(
'click',
expect.any(Function),
);
});
it('should preventDefault and send a message when a non-BYOND external link is clicked', () => {
it('should preventDefault and send a message when a non-BYOND external link is clicked', ({
expect,
}) => {
const externalLink = {
tagName: 'A',
getAttribute: () => 'https://example.com',
parentElement: document.body,
};
const byond = { sendMessage: jest.fn() };
const byond = { sendMessage: vi.fn() };
// @ts-ignore
global.Byond = byond;
const evt = { target: externalLink, preventDefault: jest.fn() };
const evt = { target: externalLink, preventDefault: vi.fn() };
clickHandler(evt);
expect(evt.preventDefault).toHaveBeenCalled();
@@ -41,34 +45,36 @@ describe('captureExternalLinks', () => {
});
});
it('should not preventDefault or send a message when a BYOND link is clicked', () => {
it('should not preventDefault or send a message when a BYOND link is clicked', ({
expect,
}) => {
const byondLink = {
tagName: 'A',
getAttribute: () => 'byond://server-address',
parentElement: document.body,
};
const byond = { sendMessage: jest.fn() };
const byond = { sendMessage: vi.fn() };
// @ts-ignore
global.Byond = byond;
const evt = { target: byondLink, preventDefault: jest.fn() };
const evt = { target: byondLink, preventDefault: vi.fn() };
clickHandler(evt);
expect(evt.preventDefault).not.toHaveBeenCalled();
expect(byond.sendMessage).not.toHaveBeenCalled();
});
it('should add https:// to www links', () => {
it('should add https:// to www links', ({ expect }) => {
const wwwLink = {
tagName: 'A',
getAttribute: () => 'www.example.com',
parentElement: document.body,
};
const byond = { sendMessage: jest.fn() };
const byond = { sendMessage: vi.fn() };
// @ts-ignore
global.Byond = byond;
const evt = { target: wwwLink, preventDefault: jest.fn() };
const evt = { target: wwwLink, preventDefault: vi.fn() };
clickHandler(evt);
expect(byond.sendMessage).toHaveBeenCalledWith({
+8 -5
View File
@@ -3,9 +3,7 @@
"name": "tgui",
"version": "6.0.0",
"dependencies": {
"@popperjs/core": "^2.11.8",
"@types/marked": "4.3.2",
"@types/react": "^19.1.0",
"@floating-ui/react": "^0.27.5",
"common": "workspace:*",
"dateformat": "^5.0.3",
"dompurify": "^3.2.4",
@@ -16,8 +14,13 @@
"marked": "^4.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-popper": "^2.3.0",
"tgui-core": "^2.0.5",
"tgui-core": "^3.0.4",
"tgui-dev-server": "workspace:*"
},
"devDependencies": {
"@types/marked": "4.3.2",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.2",
"vitest": "^3.1.1"
}
}
+19 -33
View File
@@ -11,65 +11,51 @@ let initialRender: string | boolean = true;
let suspended = false;
// These functions are used purely for profiling.
export const resumeRenderer = () => {
export function resumeRenderer() {
initialRender = initialRender || 'resumed';
suspended = false;
};
}
export const suspendRenderer = () => {
export function suspendRenderer() {
suspended = true;
};
type CreateRenderer = <T extends unknown[] = [unknown]>(
getVNode?: (...args: T) => ReactNode,
) => (...args: T) => void;
}
enum Render {
Start = 'render/start',
Finish = 'render/finish',
}
// prettier-ignore
export const createRenderer: CreateRenderer = (getVNode) => (...args) => {
export function render(component: ReactNode) {
perf.mark(Render.Start);
// Start rendering
if (!reactRoot) {
const element = document.getElementById('react-root');
reactRoot = createRoot(element!);
}
if (getVNode) {
reactRoot.render(getVNode(...args));
}
else {
reactRoot.render(args[0] as any);
}
reactRoot.render(component);
perf.mark(Render.Finish);
if (suspended) {
return;
}
// Report rendering time
if (process.env.NODE_ENV !== 'production') {
if (initialRender === 'resumed') {
logger.log('rendered in',
perf.measure(Render.Start, Render.Finish));
}
else if (initialRender) {
logger.log('rendered in', perf.measure(Render.Start, Render.Finish));
} else if (initialRender) {
logger.debug('serving from:', location.href);
logger.debug('bundle entered in',
perf.measure('inception', 'init'));
logger.debug('initialized in',
perf.measure('init', Render.Start));
logger.log('rendered in',
perf.measure(Render.Start, Render.Finish));
logger.log('fully loaded in',
perf.measure('inception', Render.Finish));
}
else {
logger.debug('rendered in',
perf.measure(Render.Start, Render.Finish));
logger.debug('bundle entered in', perf.measure('inception', 'init'));
logger.debug('initialized in', perf.measure('init', Render.Start));
logger.log('rendered in', perf.measure(Render.Start, Render.Finish));
logger.log('fully loaded in', perf.measure('inception', Render.Finish));
} else {
logger.debug('rendered in', perf.measure(Render.Start, Render.Finish));
}
}
if (initialRender) {
initialRender = false;
}
};
}
+13 -8
View File
@@ -6,7 +6,7 @@
import { useBackend } from './backend';
import { useDebug } from './debug';
import { LoadingScreen } from './interfaces/common/LoadingToolbox';
import { LoadingScreen } from './interfaces/common/LoadingScreen';
import { Window } from './layouts';
const requireInterface = require.context('./interfaces');
@@ -32,16 +32,16 @@ export const routingError =
};
// Displays an empty Window with scrollable content
const SuspendedWindow = () => {
function SuspendedWindow() {
return (
<Window>
<Window.Content scrollable />
</Window>
);
};
}
// Displays a loading screen with a spinning icon
const RefreshingWindow = () => {
function RefreshingWindow() {
return (
<Window title="Loading">
<Window.Content>
@@ -49,10 +49,10 @@ const RefreshingWindow = () => {
</Window.Content>
</Window>
);
};
}
// Get the component for the current route
export const getRoutedComponent = () => {
export function getRoutedComponent() {
const { suspended, config } = useBackend();
const { kitchenSink = false } = useDebug();
@@ -62,6 +62,7 @@ export const getRoutedComponent = () => {
if (config?.refreshing) {
return RefreshingWindow;
}
if (process.env.NODE_ENV !== 'production') {
// Show a kitchen sink
if (kitchenSink) {
@@ -69,13 +70,14 @@ export const getRoutedComponent = () => {
}
}
const name = config?.interface;
const name = config?.interface?.name;
const interfacePathBuilders = [
(name: string) => `./${name}.tsx`,
(name: string) => `./${name}.jsx`,
(name: string) => `./${name}/index.tsx`,
(name: string) => `./${name}/index.jsx`,
];
let esModule;
while (!esModule && interfacePathBuilders.length > 0) {
const interfacePathBuilder = interfacePathBuilders.shift()!;
@@ -88,12 +90,15 @@ export const getRoutedComponent = () => {
}
}
}
if (!esModule) {
return routingError('notFound', name);
}
const Component = esModule[name];
if (!Component) {
return routingError('missingExport', name);
}
return Component;
};
}
+12 -4
View File
@@ -1,14 +1,18 @@
import { describe, it } from 'vitest';
import { sanitizeText } from './sanitize';
describe('sanitizeText', () => {
it('should sanitize basic HTML input', () => {
it('should sanitize basic HTML input', ({ expect }) => {
const input = '<b>Hello, world!</b><script>alert("hack")</script>';
const expected = '<b>Hello, world!</b>';
const result = sanitizeText(input);
expect(result).toBe(expected);
});
it('should sanitize advanced HTML input when advHtml flag is true', () => {
it('should sanitize advanced HTML input when advHtml flag is true', ({
expect,
}) => {
const input =
'<b>Hello, world!</b><iframe src="https://example.com"></iframe>';
const expected = '<b>Hello, world!</b>';
@@ -16,7 +20,9 @@ describe('sanitizeText', () => {
expect(result).toBe(expected);
});
it('should allow specific HTML tags when tags array is provided', () => {
it('should allow specific HTML tags when tags array is provided', ({
expect,
}) => {
const input = '<b>Hello, world!</b><span>Goodbye, world!</span>';
const tags = ['b'];
const expected = '<b>Hello, world!</b>Goodbye, world!';
@@ -24,7 +30,9 @@ describe('sanitizeText', () => {
expect(result).toBe(expected);
});
it('should allow advanced HTML tags when advTags array is provided and advHtml flag is true', () => {
it('should allow advanced HTML tags when advTags array is provided and advHtml flag is true', ({
expect,
}) => {
const input =
'<b>Hello, world!</b><iframe src="https://example.com"></iframe>';
const advTags = ['iframe'];

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