@usableapp/cardds 0.7.90 → 0.7.92

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.
@@ -1,4 +1,4 @@
1
- import type { ComponentProps, ReactNode } from 'react';
1
+ import { type ComponentProps, type ReactNode } from 'react';
2
2
  import type { StickyTone } from './tones.js';
3
3
  export interface StickyProps extends Omit<ComponentProps<'div'>, 'onClick'> {
4
4
  /** the whole note is a button: it renders a `<button>` and keeps its fold. Not with `corner` (a button cannot hold a button) */
@@ -24,8 +24,10 @@ export interface StickyProps extends Omit<ComponentProps<'div'>, 'onClick'> {
24
24
  }
25
25
  /**
26
26
  * Sticky — a paper note: a flat pastel SQUARE of one size (168, the knob `--sticky-size`), square corners, a soft shadow under it.
27
- * Every note is that one square whatever it says: words it cannot hold are cut, never the note grown.
27
+ * Every note is that one square whatever it says, never grown: words it cannot hold are written one size smaller (`--sticky-small-font`, the label size:
28
+ * six lines instead of four and a half); words that do not fit even then are cut where their room ends. The note measures its own words.
29
+ * A `StickyField` on it stops at that room: the note refuses the input that would not fit, and the app's `onChange` is not called for it.
28
30
  * THE FOLD SAYS UNREAD: the bottom-right corner folded over = not read yet; `flat` = read. A folded note says so to a screen reader too (`unreadLabel`).
29
31
  * The outer box holds the tone and the shadow; the paper inside is the part that is cut (a clip would cut the shadow too).
30
32
  */
31
- export declare function Sticky({ tone, flat, unreadLabel, head, date, tilt, foot, corner, stuck, onClick, className, children, ...rest }: StickyProps): import("react").JSX.Element;
33
+ export declare function Sticky({ tone, flat, unreadLabel, head, date, tilt, foot, corner, stuck, onClick, className, children, ref, ...rest }: StickyProps): import("react").JSX.Element;
@@ -1,16 +1,77 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
3
  import { cx } from '../cx.js';
3
4
  import { pressable } from '../pressable.js';
5
+ import { setRefs, watchSize } from '../state.js';
4
6
  import { word } from '../words.js';
7
+ import { fitSticky } from './fit.js';
5
8
  /**
6
9
  * Sticky — a paper note: a flat pastel SQUARE of one size (168, the knob `--sticky-size`), square corners, a soft shadow under it.
7
- * Every note is that one square whatever it says: words it cannot hold are cut, never the note grown.
10
+ * Every note is that one square whatever it says, never grown: words it cannot hold are written one size smaller (`--sticky-small-font`, the label size:
11
+ * six lines instead of four and a half); words that do not fit even then are cut where their room ends. The note measures its own words.
12
+ * A `StickyField` on it stops at that room: the note refuses the input that would not fit, and the app's `onChange` is not called for it.
8
13
  * THE FOLD SAYS UNREAD: the bottom-right corner folded over = not read yet; `flat` = read. A folded note says so to a screen reader too (`unreadLabel`).
9
14
  * The outer box holds the tone and the shadow; the paper inside is the part that is cut (a clip would cut the shadow too).
10
15
  */
11
- export function Sticky({ tone = 'custard', flat, unreadLabel, head, date, tilt, foot, corner, stuck, onClick, className, children, ...rest }) {
16
+ export function Sticky({ tone = 'custard', flat, unreadLabel, head, date, tilt, foot, corner, stuck, onClick, className, children, ref, ...rest }) {
17
+ const own = useRef(null);
18
+ const [small, setSmall] = useState(false);
19
+ const last = useRef('');
20
+ const composing = useRef(false);
21
+ const fieldOf = () => own.current?.querySelector('.sticky-field') ?? null;
22
+ useLayoutEffect(() => {
23
+ const note = own.current;
24
+ if (!note)
25
+ return;
26
+ setSmall(fitSticky(note) !== 'full');
27
+ const field = fieldOf();
28
+ if (field && !composing.current)
29
+ last.current = field.value;
30
+ }, [children, head, date, foot]); // a chat re-renders every note on each keystroke in its answer field; only a note whose words changed is measured again (a field is an element, new every render)
31
+ useEffect(() => {
32
+ const note = own.current, paper = note?.querySelector('.sticky__paper');
33
+ if (!note || !paper)
34
+ return;
35
+ const measure = () => setSmall(fitSticky(note) !== 'full');
36
+ /* A native listener on the paper runs before React's own at the root, so stopping the event here keeps the app's `onChange` from
37
+ hearing an input the note refused. `dropped` is for a composition: the app has heard it already, so it is told the words went back. */
38
+ const refuse = (field, dropped) => {
39
+ const added = field.value.length - last.current.length, at = field.selectionStart - added;
40
+ if (dropped)
41
+ Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(field, last.current);
42
+ else
43
+ field.value = last.current; // the prototype's setter, so React's value tracker sees the change and the input dispatched next reaches onChange
44
+ field.setSelectionRange(Math.max(0, at), Math.max(0, at));
45
+ if (dropped)
46
+ field.dispatchEvent(new Event('input', { bubbles: true }));
47
+ };
48
+ const check = (e, dropped) => {
49
+ const field = fieldOf();
50
+ if (!field || e.target !== field)
51
+ return measure();
52
+ if (fitSticky(note) === 'over' && field.value.length > last.current.length) {
53
+ if (!dropped)
54
+ e.stopPropagation();
55
+ refuse(field, dropped);
56
+ fitSticky(note);
57
+ }
58
+ else
59
+ last.current = field.value;
60
+ measure();
61
+ };
62
+ const onInput = (e) => (e.isComposing ? measure() : check(e, false));
63
+ const onComposing = () => { composing.current = true; };
64
+ const onComposed = (e) => { composing.current = false; check(e, true); };
65
+ paper.addEventListener('input', onInput);
66
+ paper.addEventListener('compositionstart', onComposing);
67
+ paper.addEventListener('compositionend', onComposed);
68
+ const body = paper.querySelector('.sticky__body');
69
+ const stop = watchSize([body], measure);
70
+ document.fonts?.ready.then(measure);
71
+ return () => { paper.removeEventListener('input', onInput); paper.removeEventListener('compositionstart', onComposing); paper.removeEventListener('compositionend', onComposed); stop(); };
72
+ }, []);
12
73
  const notched = corner != null;
13
74
  const { tap, Box, boxProps } = pressable(notched ? undefined : onClick, 'div', rest);
14
75
  const Part = tap ? 'span' : 'div';
15
- return (_jsxs(Box, { className: cx('sticky', `sticky--${tone}`, flat && 'sticky--flat', notched && 'sticky--corner', tilt && `sticky--tilt-${tilt}`, tap && 'sticky--tap', className), ...boxProps, children: [_jsxs(Part, { className: "sticky__paper", children: [(head != null || date != null) && _jsxs(Part, { className: "sticky__head", children: [head, date] }), _jsx(Part, { className: "sticky__body", children: children }), foot != null && _jsx(Part, { className: "sticky__foot", children: foot }), !flat && !notched && _jsx("span", { className: "sticky__fold", "aria-hidden": "true" }), !flat && !notched && _jsx("span", { className: "sticky__state t-sr", children: unreadLabel ?? word('unread') })] }), stuck != null && _jsx(Part, { className: "sticky__stuck", children: stuck }), notched && _jsx("div", { className: "sticky__corner", children: corner })] }));
76
+ return (_jsxs(Box, { ref: setRefs(own, ref), className: cx('sticky', `sticky--${tone}`, small && 'sticky--small', flat && 'sticky--flat', notched && 'sticky--corner', tilt && `sticky--tilt-${tilt}`, tap && 'sticky--tap', className), ...boxProps, children: [_jsxs(Part, { className: "sticky__paper", children: [(head != null || date != null) && _jsxs(Part, { className: "sticky__head", children: [head, date] }), _jsx(Part, { className: "sticky__body", children: children }), foot != null && _jsx(Part, { className: "sticky__foot", children: foot }), !flat && !notched && _jsx("span", { className: "sticky__fold", "aria-hidden": "true" }), !flat && !notched && _jsx("span", { className: "sticky__state t-sr", children: unreadLabel ?? word('unread') })] }), stuck != null && _jsx(Part, { className: "sticky__stuck", children: stuck }), notched && _jsx("div", { className: "sticky__corner", children: corner })] }));
16
77
  }
@@ -3,5 +3,9 @@ export interface StickyFieldProps extends ComponentProps<'textarea'> {
3
3
  /** what is being written — the field's accessible name (it has no visible label) */
4
4
  label: string;
5
5
  }
6
- /** StickyField — the words written ON a Sticky: a bare textarea in the note's own type and ink, filling the note. */
6
+ /**
7
+ * StickyField — the words written ON a Sticky: a bare textarea in the note's own type and ink, filling the note.
8
+ * A note being written stops at its paper's room: the `Sticky` it is on refuses the input that would not fit (even one size smaller), and the
9
+ * app's `onChange` is not called for it. `maxLength` is still the app's to set on top.
10
+ */
7
11
  export declare function StickyField({ label, className, ...rest }: StickyFieldProps): import("react").JSX.Element;
@@ -1,6 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
- /** StickyField — the words written ON a Sticky: a bare textarea in the note's own type and ink, filling the note. */
3
+ /**
4
+ * StickyField — the words written ON a Sticky: a bare textarea in the note's own type and ink, filling the note.
5
+ * A note being written stops at its paper's room: the `Sticky` it is on refuses the input that would not fit (even one size smaller), and the
6
+ * app's `onChange` is not called for it. `maxLength` is still the app's to set on top.
7
+ */
4
8
  export function StickyField({ label, className, ...rest }) {
5
9
  return _jsx("textarea", { className: cx('sticky-field', className), "aria-label": label, ...rest });
6
10
  }
@@ -0,0 +1,6 @@
1
+ export type StickyFit = 'full' | 'small' | 'over';
2
+ /** How a note's words sit on it, measured: `full` at the note's size, `small` once written one size smaller, `over` when even that is too much.
3
+ * Leaves `sticky--small` on the note as the answer says (on for `small` and `over`). A textarea scrolls inside itself, so when the
4
+ * words are a `StickyField` it is the field that is measured, not the body round it. A body under a heading on a board is
5
+ * line-clamped to what it shows and never shrinks. */
6
+ export declare function fitSticky(note: HTMLElement): StickyFit;
@@ -0,0 +1,17 @@
1
+ const SMALL = 'sticky--small';
2
+ /** How a note's words sit on it, measured: `full` at the note's size, `small` once written one size smaller, `over` when even that is too much.
3
+ * Leaves `sticky--small` on the note as the answer says (on for `small` and `over`). A textarea scrolls inside itself, so when the
4
+ * words are a `StickyField` it is the field that is measured, not the body round it. A body under a heading on a board is
5
+ * line-clamped to what it shows and never shrinks. */
6
+ export function fitSticky(note) {
7
+ note.classList.remove(SMALL);
8
+ const body = note.querySelector('.sticky__body');
9
+ if (!body || getComputedStyle(body).webkitLineClamp !== 'none')
10
+ return 'full';
11
+ const words = body.querySelector(':scope > .sticky-field') ?? body;
12
+ const cut = () => words.scrollHeight > words.clientHeight;
13
+ if (!cut())
14
+ return 'full';
15
+ note.classList.add(SMALL);
16
+ return cut() ? 'over' : 'small';
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.90",
3
+ "version": "0.7.92",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "card-first mobile design system, React-first: the components in src/ are thin wrappers over the CSS contract (css/*.css stays the only truth); gallery/ shows every story live (npm run dev), tests/ measures the geometry.",