@nick-skriabin/glyph 0.1.30 → 0.1.32

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/README.md CHANGED
@@ -135,6 +135,16 @@ Text input field with cursor and placeholder support.
135
135
 
136
136
  Supports `multiline` for multi-line editing, `autoFocus` for automatic focus on mount. The cursor is always visible when focused.
137
137
 
138
+ **Input types** for validation:
139
+
140
+ ```tsx
141
+ // Text input (default) - accepts any character
142
+ <Input type="text" value={name} onChange={setName} />
143
+
144
+ // Number input - only accepts digits, decimal point, minus sign
145
+ <Input type="number" value={age} onChange={setAge} placeholder="0" />
146
+ ```
147
+
138
148
  **Input masking** with `onBeforeChange` for validation/formatting:
139
149
 
140
150
  ```tsx
@@ -336,6 +346,25 @@ Declarative keyboard shortcut. Renders nothing.
336
346
  <Keybind keypress="q" onPress={() => exit()} />
337
347
  ```
338
348
 
349
+ **Modifiers:** `ctrl`, `alt`, `shift`, `meta` (Cmd/Super). Combine with `+`: `"ctrl+shift+p"`, `"alt+return"`.
350
+
351
+ **Priority keybinds:** Use `priority` prop to run BEFORE focused input handlers. Useful for keybinds that should work even when an Input is focused:
352
+
353
+ ```tsx
354
+ <Keybind keypress="ctrl+return" onPress={submit} priority />
355
+ <Keybind keypress="alt+return" onPress={submit} priority />
356
+ ```
357
+
358
+ **Terminal configuration:** Some keybinds like `ctrl+return` require terminal support:
359
+
360
+ | Terminal | Configuration |
361
+ |----------|---------------|
362
+ | **Ghostty** | Add to `~/.config/ghostty/config`: `keybind = ctrl+enter=text:\x1b[13;5~` |
363
+ | **iTerm2** | Profiles → Keys → General → Enable "CSI u" mode |
364
+ | **Kitty/WezTerm** | Works out of the box |
365
+
366
+ `alt+return` works universally without configuration.
367
+
339
368
  ### `<Progress>`
340
369
 
341
370
  Determinate or indeterminate progress bar. Uses `useLayout` to measure actual width and renders block characters.
@@ -460,6 +489,59 @@ const { focused, focus } = useFocus(ref);
460
489
  </Box>
461
490
  ```
462
491
 
492
+ ### `useFocusable(options)`
493
+
494
+ Make any element focusable with full keyboard support. Perfect for building custom interactive components.
495
+
496
+ ```tsx
497
+ import { useFocusable, Box, Text } from "@nick-skriabin/glyph";
498
+
499
+ function CustomPicker({ items, onSelect }) {
500
+ const [selected, setSelected] = useState(0);
501
+
502
+ const { ref, isFocused } = useFocusable({
503
+ onKeyPress: (key) => {
504
+ if (key.name === "up") {
505
+ setSelected(s => Math.max(0, s - 1));
506
+ return true; // Consume the key
507
+ }
508
+ if (key.name === "down") {
509
+ setSelected(s => Math.min(items.length - 1, s + 1));
510
+ return true;
511
+ }
512
+ if (key.name === "return") {
513
+ onSelect(items[selected]);
514
+ return true;
515
+ }
516
+ return false; // Let other handlers process
517
+ },
518
+ onFocus: () => console.log("Picker focused"),
519
+ onBlur: () => console.log("Picker blurred"),
520
+ disabled: false, // Set to true to skip in tab order
521
+ });
522
+
523
+ return (
524
+ <Box
525
+ ref={ref}
526
+ focusable
527
+ style={{
528
+ border: "round",
529
+ borderColor: isFocused ? "cyan" : "gray",
530
+ padding: 1,
531
+ }}
532
+ >
533
+ {items.map((item, i) => (
534
+ <Text key={i} style={{ inverse: i === selected }}>
535
+ {i === selected ? "> " : " "}{item}
536
+ </Text>
537
+ ))}
538
+ </Box>
539
+ );
540
+ }
541
+ ```
542
+
543
+ Returns `{ ref, isFocused, focus, focusId }`. The `ref` must be attached to an element with `focusable` prop.
544
+
463
545
  ### `useLayout(nodeRef)`
464
546
 
465
547
  Subscribe to a node's computed layout.
package/dist/index.cjs CHANGED
@@ -2394,7 +2394,8 @@ function Input(props) {
2394
2394
  style,
2395
2395
  focusedStyle,
2396
2396
  multiline,
2397
- autoFocus
2397
+ autoFocus,
2398
+ type = "text"
2398
2399
  } = props;
2399
2400
  const [internalValue, setInternalValue] = React15.useState(defaultValue);
2400
2401
  const [cursorPos, setCursorPos] = React15.useState(defaultValue.length);
@@ -2433,7 +2434,8 @@ function Input(props) {
2433
2434
  onKeyPress,
2434
2435
  onBeforeChange,
2435
2436
  multiline: multiline ?? false,
2436
- innerWidth
2437
+ innerWidth,
2438
+ type
2437
2439
  });
2438
2440
  stateRef.current = {
2439
2441
  isControlled,
@@ -2441,7 +2443,8 @@ function Input(props) {
2441
2443
  onKeyPress,
2442
2444
  onBeforeChange,
2443
2445
  multiline: multiline ?? false,
2444
- innerWidth
2446
+ innerWidth,
2447
+ type
2445
2448
  };
2446
2449
  React15.useEffect(() => {
2447
2450
  if (!focusCtx || !focusIdRef.current || !nodeRef.current) return;
@@ -2658,6 +2661,15 @@ function Input(props) {
2658
2661
  if (key.name.length > 1) return false;
2659
2662
  const ch = key.sequence;
2660
2663
  if (ch.length === 1 && ch.charCodeAt(0) >= 32) {
2664
+ const { type: inputType } = stateRef.current;
2665
+ if (inputType === "number") {
2666
+ const isDigit = /[0-9]/.test(ch);
2667
+ const isDecimal = ch === "." && !val.includes(".");
2668
+ const isMinus = ch === "-" && pos === 0 && !val.includes("-");
2669
+ if (!isDigit && !isDecimal && !isMinus) {
2670
+ return true;
2671
+ }
2672
+ }
2661
2673
  const newVal = val.slice(0, pos) + ch + val.slice(pos);
2662
2674
  updateValue(newVal, pos + 1);
2663
2675
  return true;
@@ -4299,6 +4311,73 @@ function useFocus(nodeRef) {
4299
4311
  }, [focusCtx, id]);
4300
4312
  return { focused: isFocused, focus };
4301
4313
  }
4314
+ function useFocusable(options = {}) {
4315
+ const { disabled, onFocus, onBlur, onKeyPress } = options;
4316
+ const focusCtx = React15.useContext(FocusContext);
4317
+ const inputCtx = React15.useContext(InputContext);
4318
+ const nodeRef = React15.useRef(null);
4319
+ const focusIdRef = React15.useRef(null);
4320
+ const [isFocused, setIsFocused] = React15.useState(false);
4321
+ const onFocusRef = React15.useRef(onFocus);
4322
+ const onBlurRef = React15.useRef(onBlur);
4323
+ const onKeyPressRef = React15.useRef(onKeyPress);
4324
+ onFocusRef.current = onFocus;
4325
+ onBlurRef.current = onBlur;
4326
+ onKeyPressRef.current = onKeyPress;
4327
+ const ref = React15.useCallback((node) => {
4328
+ nodeRef.current = node;
4329
+ if (node) {
4330
+ focusIdRef.current = node.focusId ?? null;
4331
+ } else {
4332
+ focusIdRef.current = null;
4333
+ }
4334
+ }, []);
4335
+ React15.useEffect(() => {
4336
+ if (!focusCtx || !focusIdRef.current || !nodeRef.current) return;
4337
+ return focusCtx.register(focusIdRef.current, nodeRef.current);
4338
+ }, [focusCtx]);
4339
+ React15.useEffect(() => {
4340
+ if (!focusCtx || !focusIdRef.current) return;
4341
+ focusCtx.setSkippable(focusIdRef.current, !!disabled);
4342
+ }, [focusCtx, disabled]);
4343
+ React15.useEffect(() => {
4344
+ if (!focusCtx || !focusIdRef.current) return;
4345
+ const fid = focusIdRef.current;
4346
+ const initiallyFocused = focusCtx.focusedId === fid;
4347
+ setIsFocused(initiallyFocused);
4348
+ return focusCtx.onFocusChange((newId) => {
4349
+ const nowFocused = newId === fid;
4350
+ setIsFocused((wasFocused) => {
4351
+ if (nowFocused && !wasFocused) {
4352
+ onFocusRef.current?.();
4353
+ } else if (!nowFocused && wasFocused) {
4354
+ onBlurRef.current?.();
4355
+ }
4356
+ return nowFocused;
4357
+ });
4358
+ });
4359
+ }, [focusCtx]);
4360
+ React15.useEffect(() => {
4361
+ if (!inputCtx || !focusIdRef.current || disabled) return;
4362
+ const fid = focusIdRef.current;
4363
+ const handler = (key) => {
4364
+ if (focusCtx?.focusedId !== fid) return false;
4365
+ return onKeyPressRef.current?.(key) === true;
4366
+ };
4367
+ return inputCtx.registerInputHandler(fid, handler);
4368
+ }, [inputCtx, focusCtx, disabled]);
4369
+ const focus = React15.useCallback(() => {
4370
+ if (focusCtx && focusIdRef.current) {
4371
+ focusCtx.requestFocus(focusIdRef.current);
4372
+ }
4373
+ }, [focusCtx]);
4374
+ return {
4375
+ ref,
4376
+ isFocused,
4377
+ focus,
4378
+ focusId: focusIdRef.current
4379
+ };
4380
+ }
4302
4381
  function useApp() {
4303
4382
  const ctx = React15.useContext(AppContext);
4304
4383
  if (!ctx) {
@@ -4439,6 +4518,7 @@ exports.render = render;
4439
4518
  exports.useApp = useApp;
4440
4519
  exports.useDialog = useDialog;
4441
4520
  exports.useFocus = useFocus;
4521
+ exports.useFocusable = useFocusable;
4442
4522
  exports.useInput = useInput;
4443
4523
  exports.useLayout = useLayout;
4444
4524
  exports.useToast = useToast;