@jsenv/navi 0.1.1 → 0.2.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.
@@ -46,7 +46,7 @@
46
46
  <p>
47
47
  <strong>Border radius</strong>
48
48
  </p>
49
- <Button style={{ borderRadius: "20px" }}>C</Button>
49
+ <Button style={{ "--border-radius": "20px" }}>C</Button>
50
50
  </div>
51
51
  </div>
52
52
 
@@ -55,21 +55,23 @@
55
55
  <p>
56
56
  <strong>Violet border</strong>
57
57
  </p>
58
- <Button style={{ borderColor: "violet" }}>D</Button>
58
+ <Button style={{ "--border-color": "violet" }}>D</Button>
59
59
  </div>
60
60
 
61
61
  <div>
62
62
  <p>
63
63
  <strong>Border width</strong>
64
64
  </p>
65
- <Button style={{ borderWidth: "10px" }}>D</Button>
65
+ <Button style={{ "--border-width": "10px" }}>D</Button>
66
66
  </div>
67
67
 
68
68
  <div>
69
69
  <p>
70
70
  <strong>Outline width</strong>
71
71
  </p>
72
- <Button style={{ borderWidth: "5px", outlineWidth: "5px" }}>
72
+ <Button
73
+ style={{ "--border-width": "5px", "--outline-width": "5px" }}
74
+ >
73
75
  D
74
76
  </Button>
75
77
  </div>
@@ -7,59 +7,59 @@
7
7
  <title>Input Text Demo</title>
8
8
  <style>
9
9
  body {
10
+ max-width: 1200px;
11
+ margin: 0 auto;
12
+ padding: 20px;
10
13
  font-family:
11
14
  system-ui,
12
15
  -apple-system,
13
16
  sans-serif;
14
- max-width: 1200px;
15
- margin: 0 auto;
16
- padding: 20px;
17
17
  background: #f5f5f5;
18
18
  }
19
19
  .demo-grid {
20
20
  display: grid;
21
+ margin-bottom: 30px;
21
22
  grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
22
23
  gap: 20px;
23
- margin-bottom: 30px;
24
24
  }
25
25
  .demo-card {
26
+ padding: 20px;
26
27
  background: white;
27
28
  border-radius: 8px;
28
- padding: 20px;
29
29
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
30
30
  }
31
31
  .demo-title {
32
32
  margin: 0 0 15px 0;
33
+ padding-bottom: 8px;
33
34
  color: #333;
34
- font-size: 16px;
35
35
  font-weight: 600;
36
+ font-size: 16px;
36
37
  border-bottom: 2px solid #e0e0e0;
37
- padding-bottom: 8px;
38
38
  }
39
39
  .result-display {
40
+ min-height: 20px;
40
41
  margin-top: 15px;
41
42
  padding: 12px;
43
+ font-size: 14px;
44
+ font-family: monospace;
42
45
  background: #f8f9fa;
43
46
  border: 1px solid #dee2e6;
44
47
  border-radius: 4px;
45
- min-height: 20px;
46
- font-family: monospace;
47
- font-size: 14px;
48
48
  }
49
49
  .result-success {
50
+ color: #155724;
50
51
  background: #d4edda;
51
52
  border-color: #c3e6cb;
52
- color: #155724;
53
53
  }
54
54
  .result-error {
55
+ color: #721c24;
55
56
  background: #f8d7da;
56
57
  border-color: #f5c6cb;
57
- color: #721c24;
58
58
  }
59
59
  .result-loading {
60
+ color: #0c5460;
60
61
  background: #d1ecf1;
61
62
  border-color: #bee5eb;
62
- color: #0c5460;
63
63
  }
64
64
  nav a {
65
65
  color: #007bff;
@@ -76,21 +76,21 @@
76
76
  }
77
77
  h2:first-of-type {
78
78
  margin-top: 0;
79
- border-top: none;
80
79
  padding-top: 0;
80
+ border-top: none;
81
81
  }
82
82
  .button-group {
83
83
  display: flex;
84
- gap: 10px;
85
84
  margin-top: 10px;
85
+ gap: 10px;
86
86
  }
87
87
  button {
88
88
  padding: 8px 16px;
89
+ font-size: 14px;
90
+ background: white;
89
91
  border: 1px solid #ccc;
90
92
  border-radius: 4px;
91
- background: white;
92
93
  cursor: pointer;
93
- font-size: 14px;
94
94
  }
95
95
  button:hover {
96
96
  background: #f8f9fa;
@@ -100,18 +100,12 @@
100
100
  }
101
101
  label {
102
102
  margin-bottom: 15px;
103
- font-weight: 500;
104
103
  color: #555;
104
+ font-weight: 500;
105
105
  }
106
106
  label input {
107
- display: block;
108
- margin-top: 5px;
109
- width: 100%;
107
+ margin-left: 5px;
110
108
  padding: 8px 12px;
111
- border: 1px solid #ccc;
112
- border-radius: 4px;
113
- font-size: 14px;
114
- box-sizing: border-box;
115
109
  }
116
110
  </style>
117
111
  </head>
@@ -1,16 +1,23 @@
1
- import { resolveCSSSize } from "@jsenv/dom";
2
1
  import { forwardRef } from "preact/compat";
3
- import { useContext, useImperativeHandle, useRef } from "preact/hooks";
2
+ import {
3
+ useContext,
4
+ useImperativeHandle,
5
+ useLayoutEffect,
6
+ useRef,
7
+ } from "preact/hooks";
4
8
 
9
+ import { getActionPrivateProperties } from "../../action_private_properties.js";
5
10
  import { useActionStatus } from "../../use_action_status.js";
6
11
  import { requestAction } from "../../validation/custom_constraint_validation.js";
7
12
  import { useConstraints } from "../../validation/hooks/use_constraints.js";
13
+ import { FormActionContext } from "../action_execution/form_context.js";
8
14
  import { renderActionableComponent } from "../action_execution/render_actionable_component.jsx";
9
15
  import { useAction } from "../action_execution/use_action.js";
10
16
  import { useExecuteAction } from "../action_execution/use_execute_action.js";
11
- import { LoadableInlineElement } from "../loader/loader_background.jsx";
17
+ import { LoaderBackground } from "../loader/loader_background.jsx";
12
18
  import { useAutoFocus } from "../use_auto_focus.js";
13
- import "./field_css.js";
19
+ import { initCustomField } from "./custom_field.js";
20
+ import "./navi_css_vars.js";
14
21
  import { useActionEvents } from "./use_action_events.js";
15
22
  import { useFormEvents } from "./use_form_events.js";
16
23
  import {
@@ -31,38 +38,87 @@ import {
31
38
  * So we redefine chrome styles so that loader can keep up with the actual color visible to the user
32
39
  */
33
40
  import.meta.css = /* css */ `
34
- button[data-custom] {
35
- border: none;
36
- background: none;
41
+ .navi_button {
42
+ position: relative;
37
43
  display: inline-block;
38
44
  padding: 0;
39
- }
45
+ background: none;
46
+ border: none;
47
+ outline: none;
40
48
 
41
- button[data-custom] .navi_button_content {
42
- transition-duration: 0.15s;
43
- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
44
- transition-property: transform;
45
- display: inline-flex;
46
- position: relative;
47
- padding-block: 1px;
48
- padding-inline: 6px;
49
- }
49
+ --border-width: 1px;
50
+ --outline-width: 1px;
51
+ --outer-width: calc(var(--border-width) + var(--outline-width));
52
+ --padding-x: 6px;
53
+ --padding-y: 1px;
50
54
 
51
- button[data-custom]:active .navi_button_content {
52
- transform: scale(0.9);
53
- }
55
+ --outline-color: light-dark(#4476ff, #3b82f6);
54
56
 
55
- button[data-custom]:disabled .navi_button_content {
56
- transform: none;
57
- }
57
+ --border-radius: 2px;
58
+ --border-color: light-dark(#767676, #8e8e93);
59
+ --border-color-hover: color-mix(in srgb, var(--border-color) 70%, black);
60
+ --border-color-active: color-mix(in srgb, var(--border-color) 90%, black);
61
+ --border-color-readonly: color-mix(in srgb, var(--border-color) 30%, white);
62
+ --border-color-disabled: var(--border-color-readonly);
63
+
64
+ --background-color: light-dark(#f3f4f6, #2d3748);
65
+ --background-color-hover: color-mix(
66
+ in srgb,
67
+ var(--background-color) 95%,
68
+ black
69
+ );
70
+ --background-color-readonly: var(--background-color);
71
+ --background-color-disabled: var(--background-color);
58
72
 
59
- button[data-custom] .navi_button_shadow {
73
+ --color: currentColor;
74
+ --color-readonly: color-mix(in srgb, currentColor 30%, transparent);
75
+ --color-disabled: var(--color-readonly);
76
+ }
77
+ .navi_button_content {
78
+ position: relative;
79
+ display: inline-flex;
80
+ padding-top: var(--padding-y);
81
+ padding-right: var(--padding-x);
82
+ padding-bottom: var(--padding-y);
83
+ padding-left: var(--padding-x);
84
+ color: var(--color);
85
+ background-color: var(--background-color);
86
+ border-width: var(--outer-width);
87
+ border-style: solid;
88
+ border-color: transparent;
89
+ border-radius: var(--border-radius);
90
+ outline-width: var(--border-width);
91
+ outline-style: solid;
92
+ outline-color: var(--border-color);
93
+ outline-offset: calc(-1 * (var(--border-width)));
94
+ transition-property: transform;
95
+ transition-duration: 0.15s;
96
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
97
+ }
98
+ .navi_button_shadow {
60
99
  position: absolute;
61
- inset: calc(-1 * (var(--field-border-width) + var(--field-outline-width)));
62
- pointer-events: none;
100
+ inset: calc(-1 * var(--outer-width));
63
101
  border-radius: inherit;
102
+ pointer-events: none;
64
103
  }
65
- button[data-custom]:active .navi_button_shadow {
104
+ /* Focus */
105
+ .navi_button[data-focus-visible] .navi_button_content {
106
+ --border-color: var(--outline-color);
107
+ outline-width: var(--outer-width);
108
+ outline-offset: calc(-1 * var(--outer-width));
109
+ }
110
+ /* Hover */
111
+ .navi_button[data-hover] .navi_button_content {
112
+ --border-color: var(--border-color-hover);
113
+ --background-color: var(--background-color-hover);
114
+ }
115
+ /* Active */
116
+ .navi_button[data-active] .navi_button_content {
117
+ --outline-color: var(--border-color-active);
118
+ --background-color: none;
119
+ transform: scale(0.9);
120
+ }
121
+ .navi_button[data-active] .navi_button_shadow {
66
122
  box-shadow:
67
123
  inset 0 3px 6px rgba(0, 0, 0, 0.2),
68
124
  inset 0 1px 2px rgba(0, 0, 0, 0.3),
@@ -70,9 +126,53 @@ import.meta.css = /* css */ `
70
126
  inset 2px 0 4px rgba(0, 0, 0, 0.1),
71
127
  inset -2px 0 4px rgba(0, 0, 0, 0.1);
72
128
  }
73
- button[data-custom]:disabled > .navi_button_shadow {
129
+ /* Readonly */
130
+ .navi_button[data-readonly] .navi_button_content {
131
+ --border-color: var(--border-color-disabled);
132
+ --outline-color: var(--border-color-readonly);
133
+ --background-color: var(--background-color-readonly);
134
+ --color: var(--color-readonly);
135
+ }
136
+ /* Disabled */
137
+ .navi_button[data-disabled] .navi_button_content {
138
+ --border-color: var(--border-color-disabled);
139
+ --background-color: var(--background-color-disabled);
140
+ --color: var(--color-disabled);
141
+ transform: none; /* no active effect */
142
+ }
143
+ .navi_button[data-disabled] .navi_button_shadow {
74
144
  box-shadow: none;
75
145
  }
146
+ /* Invalid */
147
+ .navi_button[aria-invalid="true"] .navi_button_content {
148
+ --border-color: var(--invalid-color);
149
+ }
150
+
151
+ /* Discrete variant */
152
+ .navi_button[data-discrete] .navi_button_content {
153
+ --background-color: transparent;
154
+ --border-color: transparent;
155
+ }
156
+ .navi_button[data-discrete][data-hover] .navi_button_content {
157
+ --border-color: var(--border-color-hover);
158
+ }
159
+ .navi_button[data-discrete][data-readonly] .navi_button_content {
160
+ --border-color: transparent;
161
+ }
162
+ .navi_button[data-discrete][data-disabled] .navi_button_content {
163
+ --border-color: transparent;
164
+ }
165
+ button[data-discrete] {
166
+ background-color: transparent;
167
+ border-color: transparent;
168
+ }
169
+ button[data-discrete]:hover {
170
+ border-color: revert;
171
+ }
172
+ button[data-discrete][data-readonly],
173
+ button[data-discrete][data-disabled] {
174
+ border-color: transparent;
175
+ }
76
176
  `;
77
177
  export const Button = forwardRef((props, ref) => {
78
178
  return renderActionableComponent(props, ref, {
@@ -94,9 +194,8 @@ const ButtonBasic = forwardRef((props, ref) => {
94
194
  loading,
95
195
  constraints = [],
96
196
  autoFocus,
97
- appearance = "custom",
197
+ appearance = "navi",
98
198
  discrete,
99
- style = {},
100
199
  children,
101
200
  ...rest
102
201
  } = props;
@@ -109,57 +208,49 @@ const ButtonBasic = forwardRef((props, ref) => {
109
208
  loading || (contextLoading && contextLoadingElement === innerRef.current);
110
209
  const innerReadOnly = readOnly || contextReadOnly || innerLoading;
111
210
  const innerDisabled = disabled || contextDisabled;
112
- let {
113
- border,
114
- borderWidth = border === "none" || discrete ? 0 : 1,
115
- outlineWidth = discrete ? 0 : 1,
116
- borderColor = "light-dark(#767676, #8e8e93)",
117
- ...restStyle
118
- } = style;
119
- borderWidth = resolveCSSSize(borderWidth);
120
- outlineWidth = resolveCSSSize(outlineWidth);
211
+
212
+ let buttonChildren;
213
+ if (appearance === "navi") {
214
+ buttonChildren = <NaviButton buttonRef={innerRef}>{children}</NaviButton>;
215
+ } else {
216
+ buttonChildren = children;
217
+ }
121
218
 
122
219
  return (
123
220
  <button
124
221
  {...rest}
125
222
  ref={innerRef}
126
- data-custom={appearance === "custom" ? "" : undefined}
127
- data-readonly-silent={innerReadOnly ? "" : undefined}
223
+ className={appearance === "navi" ? "navi_button" : undefined}
224
+ data-discrete={discrete ? "" : undefined}
128
225
  data-readonly={innerReadOnly ? "" : undefined}
226
+ data-readonly-silent={innerLoading ? "" : undefined}
227
+ data-disabled={innerDisabled ? "" : undefined}
228
+ data-validation-message-arrow-x="center"
129
229
  aria-busy={innerLoading}
130
- style={{
131
- ...restStyle,
132
- }}
133
230
  >
134
- <LoadableInlineElement
231
+ <LoaderBackground
135
232
  loading={innerLoading}
136
233
  inset={-1}
137
234
  color="light-dark(#355fcc, #3b82f6)"
138
235
  >
139
- <span
140
- className="navi_button_content"
141
- data-field=""
142
- data-field-with-background=""
143
- data-field-with-hover=""
144
- data-field-with-border={borderWidth ? "" : undefined}
145
- data-field-with-border-hover={discrete ? "" : undefined}
146
- data-field-with-background-hover={discrete ? "" : undefined}
147
- data-validation-message-arrow-x="center"
148
- data-readonly={innerReadOnly ? "" : undefined}
149
- data-disabled={innerDisabled ? "" : undefined}
150
- style={{
151
- "--field-border-width": `${borderWidth}px`,
152
- "--field-outline-width": `${outlineWidth}px`,
153
- "--field-border-color": borderColor,
154
- }}
155
- >
156
- {children}
157
- <span className="navi_button_shadow"></span>
158
- </span>
159
- </LoadableInlineElement>
236
+ {buttonChildren}
237
+ </LoaderBackground>
160
238
  </button>
161
239
  );
162
240
  });
241
+ const NaviButton = ({ buttonRef, children }) => {
242
+ const ref = useRef();
243
+ useLayoutEffect(() => {
244
+ return initCustomField(buttonRef.current, buttonRef.current);
245
+ }, []);
246
+
247
+ return (
248
+ <span ref={ref} className="navi_button_content">
249
+ {children}
250
+ <span className="navi_button_shadow"></span>
251
+ </span>
252
+ );
253
+ };
163
254
 
164
255
  const ButtonWithAction = forwardRef((props, ref) => {
165
256
  const {
@@ -217,13 +308,22 @@ const ButtonWithAction = forwardRef((props, ref) => {
217
308
  });
218
309
 
219
310
  const ButtonInsideForm = forwardRef((props, ref) => {
220
- const { formContext, type, onClick, children, loading, ...rest } = props;
221
- const formLoading = formContext.loading;
311
+ const {
312
+ // eslint-disable-next-line no-unused-vars
313
+ formContext,
314
+ type,
315
+ onClick,
316
+ children,
317
+ loading,
318
+ readOnly,
319
+ ...rest
320
+ } = props;
222
321
  const innerRef = useRef();
223
322
  useImperativeHandle(ref, () => innerRef.current);
224
323
 
225
324
  const wouldSubmitFormByType = type === "submit" || type === "image";
226
- const innerLoading = loading || (formLoading && wouldSubmitFormByType);
325
+ const innerLoading = loading;
326
+ const innerReadOnly = readOnly;
227
327
  const handleClick = (event) => {
228
328
  const buttonElement = innerRef.current;
229
329
  const { form } = buttonElement;
@@ -262,6 +362,7 @@ const ButtonInsideForm = forwardRef((props, ref) => {
262
362
  ref={innerRef}
263
363
  type={type}
264
364
  loading={innerLoading}
365
+ readOnly={innerReadOnly}
265
366
  onClick={(event) => {
266
367
  handleClick(event);
267
368
  onClick?.(event);
@@ -273,8 +374,10 @@ const ButtonInsideForm = forwardRef((props, ref) => {
273
374
  });
274
375
 
275
376
  const ButtonWithActionInsideForm = forwardRef((props, ref) => {
377
+ const formAction = useContext(FormActionContext);
276
378
  const {
277
- formContext,
379
+ // eslint-disable-next-line no-unused-vars
380
+ formContext, // to avoid passing it to the button element
278
381
  type,
279
382
  action,
280
383
  loading,
@@ -294,7 +397,7 @@ const ButtonWithActionInsideForm = forwardRef((props, ref) => {
294
397
  `<Button type="${type}" /> should not have their own action`,
295
398
  );
296
399
  }
297
- const { formParamsSignal } = formContext;
400
+ const formParamsSignal = getActionPrivateProperties(formAction).paramsSignal;
298
401
  const innerRef = useRef();
299
402
  useImperativeHandle(ref, () => innerRef.current);
300
403
  const actionBoundToFormParams = useAction(action, formParamsSignal);
@@ -0,0 +1,106 @@
1
+ import { createPubSub } from "@jsenv/dom";
2
+
3
+ export const initCustomField = (customField, field) => {
4
+ const [teardown, addTeardown] = createPubSub();
5
+
6
+ const addEventListener = (element, eventType, listener) => {
7
+ element.addEventListener(eventType, listener);
8
+ return addTeardown(() => {
9
+ element.removeEventListener(eventType, listener);
10
+ });
11
+ };
12
+ const updateBooleanAttribute = (attributeName, isPresent) => {
13
+ if (isPresent) {
14
+ customField.setAttribute(attributeName, "");
15
+ } else {
16
+ customField.removeAttribute(attributeName);
17
+ }
18
+ };
19
+ const checkPseudoClasses = () => {
20
+ const hover = field.matches(":hover");
21
+ const active = field.matches(":active");
22
+ const checked = field.matches(":checked");
23
+ const focus = field.matches(":focus");
24
+ const focusVisible = field.matches(":focus-visible");
25
+ const valid = field.matches(":valid");
26
+ const invalid = field.matches(":invalid");
27
+ updateBooleanAttribute(`data-hover`, hover);
28
+ updateBooleanAttribute(`data-active`, active);
29
+ updateBooleanAttribute(`data-checked`, checked);
30
+ updateBooleanAttribute(`data-focus`, focus);
31
+ updateBooleanAttribute(`data-focus-visible`, focusVisible);
32
+ updateBooleanAttribute(`data-valid`, valid);
33
+ updateBooleanAttribute(`data-invalid`, invalid);
34
+ };
35
+
36
+ // :hover
37
+ addEventListener(field, "mouseenter", checkPseudoClasses);
38
+ addEventListener(field, "mouseleave", checkPseudoClasses);
39
+ // :active
40
+ addEventListener(field, "mousedown", checkPseudoClasses);
41
+ addEventListener(document, "mouseup", checkPseudoClasses);
42
+ // :focus
43
+ addEventListener(field, "focusin", checkPseudoClasses);
44
+ addEventListener(field, "focusout", checkPseudoClasses);
45
+ // :focus-visible
46
+ addEventListener(document, "keydown", checkPseudoClasses);
47
+ addEventListener(document, "keyup", checkPseudoClasses);
48
+ // :checked
49
+ if (field.type === "checkbox") {
50
+ // Listen to user interactions
51
+ addEventListener(field, "input", checkPseudoClasses);
52
+
53
+ // Intercept programmatic changes to .checked property
54
+ const originalDescriptor = Object.getOwnPropertyDescriptor(
55
+ HTMLInputElement.prototype,
56
+ "checked",
57
+ );
58
+ Object.defineProperty(field, "checked", {
59
+ get: originalDescriptor.get,
60
+ set(value) {
61
+ originalDescriptor.set.call(this, value);
62
+ checkPseudoClasses();
63
+ },
64
+ configurable: true,
65
+ });
66
+ addTeardown(() => {
67
+ // Restore original property descriptor
68
+ Object.defineProperty(field, "checked", originalDescriptor);
69
+ });
70
+ } else if (field.type === "radio") {
71
+ // Listen to changes on the radio group
72
+ const radioSet =
73
+ field.closest("[data-radio-list], fieldset, form") || document;
74
+ addEventListener(radioSet, "input", checkPseudoClasses);
75
+
76
+ // Intercept programmatic changes to .checked property
77
+ const originalDescriptor = Object.getOwnPropertyDescriptor(
78
+ HTMLInputElement.prototype,
79
+ "checked",
80
+ );
81
+ Object.defineProperty(field, "checked", {
82
+ get: originalDescriptor.get,
83
+ set(value) {
84
+ originalDescriptor.set.call(this, value);
85
+ checkPseudoClasses();
86
+ },
87
+ configurable: true,
88
+ });
89
+ addTeardown(() => {
90
+ // Restore original property descriptor
91
+ Object.defineProperty(field, "checked", originalDescriptor);
92
+ });
93
+ } else if (field.tagName === "INPUT") {
94
+ addEventListener(field, "input", checkPseudoClasses);
95
+ }
96
+
97
+ // just in case + catch use forcing them in chrome devtools
98
+ const interval = setInterval(() => {
99
+ checkPseudoClasses();
100
+ }, 150);
101
+ addTeardown(() => {
102
+ clearInterval(interval);
103
+ });
104
+
105
+ return teardown;
106
+ };