tinywidgets 1.3.12 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinywidgets",
3
- "version": "1.3.12",
3
+ "version": "1.5.0",
4
4
  "author": "jamesgpearce",
5
5
  "repository": "github:tinyplex/tinywidgets",
6
6
  "license": "MIT",
@@ -22,26 +22,30 @@
22
22
  "@eslint/js": "^9.30.1",
23
23
  "@types/react": "^19.2.14",
24
24
  "@types/react-dom": "^19.2.3",
25
- "cspell": "^9.7.0",
25
+ "cspell": "^10.0.0",
26
26
  "eslint": "^9.30.1",
27
27
  "eslint-plugin-import": "^2.32.0",
28
28
  "eslint-plugin-react": "^7.37.5",
29
29
  "eslint-plugin-react-hooks": "^5.2.0",
30
- "globals": "^17.4.0",
31
- "prettier": "^3.8.1",
30
+ "globals": "^17.5.0",
31
+ "prettier": "^3.8.3",
32
32
  "prettier-plugin-organize-imports": "^4.3.0",
33
- "typescript": "^5.9.3",
34
- "typescript-eslint": "^8.57.1"
33
+ "react": "^19.2.5",
34
+ "react-dom": "^19.2.5",
35
+ "typescript": "^6.0.3",
36
+ "typescript-eslint": "^8.58.2"
35
37
  },
36
38
  "exports": {
37
39
  ".": "./src/index.ts",
38
40
  "./css": "./src/index.css.ts"
39
41
  },
40
42
  "dependencies": {
41
- "@vanilla-extract/css": "^1.19.1",
42
- "lucide-react": "^0.577.0",
43
+ "@vanilla-extract/css": "^1.20.1",
44
+ "lucide-react": "^1.8.0",
45
+ "tinybase": "^8.2.0"
46
+ },
47
+ "peerDependencies": {
43
48
  "react": "^19.2.4",
44
- "react-dom": "^19.2.4",
45
- "tinybase": "^8.0.2"
49
+ "react-dom": "^19.2.4"
46
50
  }
47
- }
51
+ }
@@ -1,5 +1,10 @@
1
1
  import type {StyleRule} from '@vanilla-extract/css';
2
- import {type ComponentType, type ReactNode} from 'react';
2
+ import {
3
+ createElement,
4
+ isValidElement,
5
+ type ComponentType,
6
+ type ReactNode,
7
+ } from 'react';
3
8
  import {screens} from '../css/screens';
4
9
 
5
10
  /**
@@ -30,12 +35,18 @@ export const classNames = (
30
35
  export const renderComponentOrNode = (
31
36
  ComponentOrNode: ComponentType | ReactNode,
32
37
  fallback: ReactNode = null,
33
- ) =>
34
- ComponentOrNode instanceof Function ? (
35
- <ComponentOrNode />
36
- ) : (
37
- (ComponentOrNode ?? fallback)
38
- );
38
+ ): ReactNode => {
39
+ if (ComponentOrNode == null) {
40
+ return fallback;
41
+ }
42
+ if (isValidElement(ComponentOrNode)) {
43
+ return ComponentOrNode;
44
+ }
45
+ if (['string', 'number', 'boolean'].includes(typeof ComponentOrNode)) {
46
+ return ComponentOrNode as string | number | boolean;
47
+ }
48
+ return createElement(ComponentOrNode as ComponentType);
49
+ };
39
50
 
40
51
  export const large = (style: StyleRule) => ({
41
52
  '@media': {[`screen and (min-width: ${screens.large}px)`]: style},
@@ -12,9 +12,7 @@ export const appLayout = style({
12
12
  });
13
13
 
14
14
  export const header = style({
15
- display: 'flex',
16
15
  justifyContent: 'space-between',
17
- alignItems: 'center',
18
16
  gap: dimensions.padding,
19
17
  padding: dimensions.padding,
20
18
  position: 'fixed',
@@ -29,9 +27,7 @@ export const header = style({
29
27
  });
30
28
 
31
29
  export const topNav = style({
32
- display: 'flex',
33
30
  justifyContent: 'space-between',
34
- alignItems: 'center',
35
31
  gap: dimensions.padding,
36
32
  flex: 1,
37
33
  });
@@ -39,9 +35,6 @@ export const topNav = style({
39
35
  export const sideNavButton = style(large({display: 'none!important'}));
40
36
 
41
37
  export const title = style({
42
- display: 'flex',
43
- alignItems: 'center',
44
- gap: dimensions.padding,
45
38
  ...large({
46
39
  width: `calc(${dimensions.sideNavWidth} - 2 * ${dimensions.padding})`,
47
40
  }),
@@ -84,9 +77,7 @@ export const mainHasFooter = style({
84
77
  });
85
78
 
86
79
  export const footer = style({
87
- display: 'flex',
88
80
  justifyContent: 'right',
89
- alignItems: 'center',
90
81
  gap: dimensions.padding,
91
82
  paddingLeft: dimensions.padding,
92
83
  paddingRight: dimensions.padding,
@@ -25,6 +25,7 @@ import {
25
25
  useSideNavIsOpen,
26
26
  useToggleSideNavIsOpenCallback,
27
27
  } from '../../stores/SessionStore.tsx';
28
+ import {Axis} from '../Axis/index.tsx';
28
29
  import {Button} from '../Button/index.tsx';
29
30
  import {
30
31
  app,
@@ -165,7 +166,7 @@ const Layout = ({
165
166
  <LayoutContext.Provider value={{portal: ref.current}}>
166
167
  {sessionStoreIsReady && routeStoreIsReady && localStoreIsReady ? (
167
168
  <>
168
- <header className={header}>
169
+ <Axis as="header" className={header}>
169
170
  {hasSideNav ? (
170
171
  <Button
171
172
  variant="icon"
@@ -177,10 +178,10 @@ const Layout = ({
177
178
  <nav className={title}>
178
179
  {renderComponentOrNode(titleComponentOrNode)}
179
180
  </nav>
180
- <nav className={topNav}>
181
+ <Axis as="nav" className={topNav}>
181
182
  {renderComponentOrNode(topNavLeftComponentOrNode, <div />)}
182
183
  {renderComponentOrNode(topNavRightComponentOrNode, <div />)}
183
- </nav>
184
+ </Axis>
184
185
  <Button
185
186
  variant="icon"
186
187
  onClick={toggleDarkChoice}
@@ -197,7 +198,7 @@ const Layout = ({
197
198
  {renderComponentOrNode(sideNavComponentOrNode)}
198
199
  </nav>
199
200
  ) : null}
200
- </header>
201
+ </Axis>
201
202
  <main
202
203
  className={classNames(
203
204
  main,
@@ -208,9 +209,9 @@ const Layout = ({
208
209
  {renderComponentOrNode(mainComponentOrNode)}
209
210
  </main>
210
211
  {hasFooter ? (
211
- <footer className={footer}>
212
+ <Axis as="footer" className={footer}>
212
213
  {renderComponentOrNode(footerComponentOrNode)}
213
- </footer>
214
+ </Axis>
214
215
  ) : null}
215
216
  </>
216
217
  ) : null}
@@ -0,0 +1,13 @@
1
+ import {style, styleVariants} from '@vanilla-extract/css';
2
+
3
+ export const axis = style({
4
+ display: 'flex',
5
+ alignItems: 'center',
6
+ });
7
+
8
+ export const axisVariants = styleVariants({
9
+ horizontal: {},
10
+ vertical: {
11
+ flexDirection: 'column',
12
+ },
13
+ });
@@ -0,0 +1,82 @@
1
+ import type {MouseEventHandler, ReactNode} from 'react';
2
+ import {classNames} from '../../common/functions';
3
+ import {axis, axisVariants} from './index.css';
4
+
5
+ /**
6
+ * The `Axis` component displays its children along a flex axis, aligning them
7
+ * in the center of the cross-axis.
8
+ *
9
+ * This is useful for compact layouts where icons, avatars, images, and text
10
+ * should share a common visual center line, or for stacked layouts that should
11
+ * remain horizontally centered.
12
+ *
13
+ * @param props The props for the component.
14
+ * @returns The Axis component.
15
+ * @example
16
+ * ```tsx
17
+ * <Axis>
18
+ * <Image src="/favicon.svg" variant="logo" />
19
+ * TinyWidgets
20
+ * </Axis>
21
+ * ```
22
+ * This example shows a logo image and text aligned along the same
23
+ * horizontal axis.
24
+ * @example
25
+ * ```tsx
26
+ * <Axis variant="vertical">
27
+ * <Image src="/favicon.svg" variant="logo" />
28
+ * TinyWidgets
29
+ * </Axis>
30
+ * ```
31
+ * This example shows the same content arranged on a vertical axis.
32
+ * @icon Lucide.AlignCenterHorizontal
33
+ */
34
+ export const Axis = ({
35
+ as: Component = 'div',
36
+ variant = 'horizontal',
37
+ className,
38
+ title,
39
+ onClick,
40
+ children,
41
+ }: {
42
+ /**
43
+ * The HTML element used to wrap the axis, one of:
44
+ * - `div`
45
+ * - `nav`
46
+ * - `span`
47
+ * - `h1`
48
+ * - `header`
49
+ * - `footer`
50
+ */
51
+ readonly as?: 'div' | 'nav' | 'span' | 'h1' | 'header' | 'footer';
52
+ /**
53
+ * A variant of the axis, one of:
54
+ * - `horizontal`
55
+ * - `vertical`
56
+ */
57
+ readonly variant?: keyof typeof axisVariants;
58
+ /**
59
+ * An extra CSS class name for the component.
60
+ */
61
+ readonly className?: string;
62
+ /**
63
+ * Alternative text shown when the user hovers over the component.
64
+ */
65
+ readonly title?: string;
66
+ /**
67
+ * A handler called when the user clicks on the component.
68
+ */
69
+ readonly onClick?: MouseEventHandler<HTMLElement>;
70
+ /**
71
+ * The children of the component, arranged along the selected axis.
72
+ */
73
+ readonly children: ReactNode;
74
+ }) => (
75
+ <Component
76
+ className={classNames(axis, axisVariants[variant], className)}
77
+ title={title}
78
+ onClick={onClick}
79
+ >
80
+ {children}
81
+ </Component>
82
+ );
@@ -0,0 +1,5 @@
1
+ import {style} from '@vanilla-extract/css';
2
+
3
+ export const imageLabel = style({
4
+ gap: '0.5rem',
5
+ });
@@ -0,0 +1,72 @@
1
+ import type {ComponentType, MouseEventHandler, ReactNode} from 'react';
2
+ import {classNames, renderComponentOrNode} from '../../common/functions';
3
+ import {Axis} from '../Axis';
4
+ import {imageLabel} from './index.css';
5
+
6
+ /**
7
+ * The `ImageLabel` component displays an image and a text label along a shared
8
+ * horizontal axis.
9
+ *
10
+ * It is useful for common UI patterns such as brand marks, avatars with names,
11
+ * or any compact media-and-label combination.
12
+ *
13
+ * @param props The props for the component.
14
+ * @returns The ImageLabel component.
15
+ * @example
16
+ * ```tsx
17
+ * <ImageLabel
18
+ * image={<Image src="/favicon.svg" variant="avatar" />}
19
+ * text="TinyWidgets"
20
+ * />
21
+ * ```
22
+ * This example shows a logo image with a text label.
23
+ * @example
24
+ * ```tsx
25
+ * <ImageLabel
26
+ * as="h1"
27
+ * image={<Image src="/favicon.svg" variant="logo"/>}
28
+ * text={<b>TinyWidgets</b>}
29
+ * />
30
+ * ```
31
+ * This example shows the component used as a heading with rich text content.
32
+ * @icon Lucide.Captions
33
+ */
34
+ export const ImageLabel = ({
35
+ as = 'div',
36
+ image,
37
+ text,
38
+ className,
39
+ onClick,
40
+ }: {
41
+ /**
42
+ * The HTML element used to wrap the image label, one of:
43
+ * - `div`
44
+ * - `nav`
45
+ * - `span`
46
+ * - `h1`
47
+ * - `header`
48
+ * - `footer`
49
+ */
50
+ readonly as?: 'div' | 'nav' | 'span' | 'h1' | 'header' | 'footer';
51
+ /**
52
+ * A component or element which renders the image for the label.
53
+ */
54
+ readonly image: ComponentType | ReactNode;
55
+ /**
56
+ * A component, element, or string which renders the label text.
57
+ */
58
+ readonly text: ComponentType | ReactNode;
59
+ /**
60
+ * An extra CSS class name for the component.
61
+ */
62
+ readonly className?: string;
63
+ /**
64
+ * A handler called when the user clicks on the component.
65
+ */
66
+ readonly onClick?: MouseEventHandler<HTMLElement>;
67
+ }) => (
68
+ <Axis as={as} className={classNames(imageLabel, className)} onClick={onClick}>
69
+ {renderComponentOrNode(image)}
70
+ {renderComponentOrNode(text)}
71
+ </Axis>
72
+ );
@@ -0,0 +1,84 @@
1
+ import {keyframes, style} from '@vanilla-extract/css';
2
+ import {colors} from '../../css/colors.css';
3
+ import {dimensions} from '../../css/dimensions.css';
4
+
5
+ const spin = keyframes({
6
+ from: {transform: 'rotate(0deg)'},
7
+ to: {transform: 'rotate(360deg)'},
8
+ });
9
+
10
+ const shimmer = keyframes({
11
+ from: {transform: 'translateX(-100%)'},
12
+ to: {transform: 'translateX(100%)'},
13
+ });
14
+
15
+ export const loading = style({
16
+ display: 'grid',
17
+ gridTemplateRows: 'auto minmax(0, 1fr)',
18
+ gap: `calc(${dimensions.padding} / 1.5)`,
19
+ alignContent: 'start',
20
+ });
21
+
22
+ export const header = style({
23
+ display: 'flex',
24
+ alignItems: 'center',
25
+ gap: `calc(${dimensions.padding} / 2)`,
26
+ color: colors.foregroundDim,
27
+ });
28
+
29
+ export const spinner = style({
30
+ width: dimensions.icon,
31
+ height: dimensions.icon,
32
+ borderRadius: '999px',
33
+ border: `2px solid ${colors.backgroundHover}`,
34
+ borderTopColor: colors.accent,
35
+ animation: `${spin} .9s linear infinite`,
36
+ '@media': {
37
+ '(prefers-reduced-motion: reduce)': {
38
+ animation: 'none',
39
+ },
40
+ },
41
+ });
42
+
43
+ export const label = style({
44
+ fontSize: '.95rem',
45
+ });
46
+
47
+ export const rows = style({
48
+ position: 'relative',
49
+ display: 'grid',
50
+ gap: `calc(${dimensions.padding} / 2)`,
51
+ minHeight: 0,
52
+ overflow: 'hidden',
53
+ });
54
+
55
+ export const row = style({
56
+ position: 'relative',
57
+ overflow: 'hidden',
58
+ height: '.875rem',
59
+ borderRadius: dimensions.radius,
60
+ background: colors.background2,
61
+ selectors: {
62
+ '&::after': {
63
+ content: '""',
64
+ position: 'absolute',
65
+ inset: 0,
66
+ background: `linear-gradient(
67
+ 90deg,
68
+ transparent,
69
+ ${colors.backgroundHover},
70
+ transparent
71
+ )`,
72
+ animation: `${shimmer} 1.6s ease-in-out infinite`,
73
+ },
74
+ },
75
+ '@media': {
76
+ '(prefers-reduced-motion: reduce)': {
77
+ selectors: {
78
+ '&::after': {
79
+ animation: 'none',
80
+ },
81
+ },
82
+ },
83
+ },
84
+ });
@@ -0,0 +1,137 @@
1
+ import {useEffect, useRef, useState} from 'react';
2
+ import {classNames} from '../../common/functions';
3
+ import {
4
+ header,
5
+ label,
6
+ loading,
7
+ row,
8
+ rows as rowsClass,
9
+ spinner,
10
+ } from './index.css';
11
+
12
+ const WIDTHS = ['100%', '92%', '84%', '96%', '72%', '88%', '67%', '81%'];
13
+ const DEFAULT_ROW_COUNT = 5;
14
+
15
+ /**
16
+ * The `Loading` component displays a compact spinner with optional shimmer rows
17
+ * to reserve space while content is loading.
18
+ *
19
+ * @param props The props for the component.
20
+ * @returns The Loading component.
21
+ * @example
22
+ * ```tsx
23
+ * <Loading labelText="Loading dependencies" />
24
+ * ```
25
+ * This example shows the default loading panel. Add a CSS height to the
26
+ * component to fit more placeholder rows automatically.
27
+ * @example
28
+ * ```tsx
29
+ * <>
30
+ * <style>{`
31
+ * .tallLoading {
32
+ * height: 12rem;
33
+ * }
34
+ * `}</style>
35
+ * <Loading className="tallLoading" labelText="Loading dependencies" />
36
+ * </>
37
+ * ```
38
+ * This example uses CSS to make the loading panel taller, allowing more
39
+ * placeholder rows to fit automatically.
40
+ * @icon Lucide.LoaderCircle
41
+ */
42
+ export const Loading = ({
43
+ labelText = 'Loading',
44
+ className,
45
+ }: {
46
+ /**
47
+ * The label shown beside the spinner.
48
+ */
49
+ readonly labelText?: string;
50
+ /**
51
+ * An extra CSS class name for the component.
52
+ */
53
+ readonly className?: string;
54
+ }) => {
55
+ const rootRef = useRef<HTMLDivElement>(null);
56
+ const headerRef = useRef<HTMLDivElement>(null);
57
+ const rowsRef = useRef<HTMLDivElement>(null);
58
+ const measureRowRef = useRef<HTMLDivElement>(null);
59
+
60
+ const [rowCount, setRowCount] = useState(DEFAULT_ROW_COUNT);
61
+
62
+ useEffect(() => {
63
+ const updateRowCount = () => {
64
+ const root = rootRef.current;
65
+ const header = headerRef.current;
66
+ const rows = rowsRef.current;
67
+ const measureRow = measureRowRef.current;
68
+ if (
69
+ root == null ||
70
+ header == null ||
71
+ rows == null ||
72
+ measureRow == null
73
+ ) {
74
+ return;
75
+ }
76
+
77
+ const rootStyles = getComputedStyle(root);
78
+ const rowsStyles = getComputedStyle(rows);
79
+ const rootGap =
80
+ parseFloat(rootStyles.rowGap || rootStyles.gap || '0') || 0;
81
+ const rowsGap =
82
+ parseFloat(rowsStyles.rowGap || rowsStyles.gap || '0') || 0;
83
+ const availableHeight =
84
+ root.getBoundingClientRect().height -
85
+ header.getBoundingClientRect().height -
86
+ rootGap;
87
+ const rowHeight = measureRow.getBoundingClientRect().height;
88
+
89
+ const nextRowCount = Math.max(
90
+ 0,
91
+ Math.floor((availableHeight + rowsGap) / (rowHeight + rowsGap)),
92
+ );
93
+ setRowCount((currentRowCount) =>
94
+ currentRowCount === nextRowCount ? currentRowCount : nextRowCount,
95
+ );
96
+ };
97
+
98
+ updateRowCount();
99
+ if (typeof ResizeObserver === 'undefined') {
100
+ return;
101
+ }
102
+
103
+ const resizeObserver = new ResizeObserver(updateRowCount);
104
+ if (rootRef.current != null) {
105
+ resizeObserver.observe(rootRef.current);
106
+ }
107
+ if (headerRef.current != null) {
108
+ resizeObserver.observe(headerRef.current);
109
+ }
110
+ return () => resizeObserver.disconnect();
111
+ }, []);
112
+
113
+ return (
114
+ <div ref={rootRef} className={classNames(loading, className)}>
115
+ <div ref={headerRef} className={header}>
116
+ <span className={spinner} />
117
+ <span className={label}>{labelText}</span>
118
+ </div>
119
+ <div ref={rowsRef} className={rowsClass}>
120
+ <div
121
+ ref={measureRowRef}
122
+ className={row}
123
+ style={{position: 'absolute', width: 0, visibility: 'hidden'}}
124
+ />
125
+ {rowCount > 0
126
+ ? Array.from({length: rowCount}, (_, index) => (
127
+ <div
128
+ key={index}
129
+ className={row}
130
+ style={{width: WIDTHS[index % WIDTHS.length]}}
131
+ />
132
+ ))
133
+ : null}
134
+ </div>
135
+ </div>
136
+ );
137
+ };
@@ -7,8 +7,6 @@ export const metric = style({
7
7
  });
8
8
 
9
9
  export const metricLabel = style({
10
- display: 'flex',
11
- alignItems: 'center',
12
10
  gap: '0.5rem',
13
11
  });
14
12
 
@@ -1,6 +1,7 @@
1
1
  import type {ComponentType, ReactNode} from 'react';
2
2
  import {classNames, renderComponentOrNode} from '../../common/functions';
3
3
  import {iconSize} from '../../css/dimensions.css';
4
+ import {Axis} from '../Axis';
4
5
  import {metric, metricLabel, metricNumber} from './index.css';
5
6
 
6
7
  /**
@@ -47,10 +48,10 @@ export const Metric = ({
47
48
  readonly className?: string;
48
49
  }) => (
49
50
  <div className={classNames(metric, className)}>
50
- <div className={metricLabel}>
51
+ <Axis className={metricLabel}>
51
52
  {Icon ? <Icon className={iconSize} /> : null}
52
53
  {renderComponentOrNode(titleComponentOrNode)}
53
- </div>
54
+ </Axis>
54
55
  <div className={metricNumber}>
55
56
  {renderComponentOrNode(numberComponentOrNode)}
56
57
  </div>
@@ -2,8 +2,6 @@ import {style, styleVariants} from '@vanilla-extract/css';
2
2
  import {colors} from '../../css/colors.css';
3
3
 
4
4
  export const tag = style({
5
- display: 'flex',
6
- alignItems: 'center',
7
5
  fontSize: '0.625rem',
8
6
  lineHeight: '0.625rem',
9
7
  padding: '0.1rem 0.25rem',
@@ -1,5 +1,6 @@
1
1
  import type {ComponentType, ReactNode} from 'react';
2
2
  import {classNames, renderComponentOrNode} from '../../common/functions';
3
+ import {Axis} from '../Axis';
3
4
  import {tag, tagIcon, tagVariants} from './index.css';
4
5
 
5
6
  /**
@@ -61,12 +62,12 @@ export const Tag = ({
61
62
  }) => {
62
63
  const icon = Icon ? <Icon className={tagIcon} /> : null;
63
64
  return (
64
- <div
65
+ <Axis
65
66
  className={classNames(tag, tagVariants[variant], className)}
66
67
  title={alt}
67
68
  >
68
69
  {icon}
69
70
  {renderComponentOrNode(title)}
70
- </div>
71
+ </Axis>
71
72
  );
72
73
  };
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export {App} from './components/App/index.tsx';
2
+ export {Axis} from './components/Axis/index.tsx';
2
3
  export {Button} from './components/Button/index.tsx';
3
4
  export {Card} from './components/Card/index.tsx';
4
5
  export {Checkbox} from './components/Checkbox/index.tsx';
@@ -8,6 +9,8 @@ export {Detail} from './components/Detail/index.tsx';
8
9
  export {Flyout} from './components/Flyout/index.tsx';
9
10
  export {Hr} from './components/Hr/index.tsx';
10
11
  export {Image} from './components/Image/index.tsx';
12
+ export {ImageLabel} from './components/ImageLabel/index.tsx';
13
+ export {Loading} from './components/Loading/index.tsx';
11
14
  export {Metric} from './components/Metric/index.tsx';
12
15
  export {Row} from './components/Row/index.tsx';
13
16
  export {Select} from './components/Select/index.tsx';