@enigmax/primitives 0.27.0 → 0.28.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/dist/{chunk-VKL3DEIQ.js → chunk-6WIGVIQF.js} +58 -37
- package/dist/{chunk-O2UJ5ZIX.js → chunk-CB7K6VW4.js} +1 -1
- package/dist/{chunk-2RIO4VDV.js → chunk-IQMHDRJF.js} +2 -2
- package/dist/{chunk-PVDU6AZU.js → chunk-JPBVKXOL.js} +1 -1
- package/dist/{chunk-CPJJ3FPW.js → chunk-RRPWJAIJ.js} +26 -12
- package/dist/chunk-RXEO4WPI.js +111 -0
- package/dist/{chunk-RMNXVWQ4.js → chunk-YHH5U2NW.js} +1 -1
- package/dist/{color-T63FLJNH.js → color-WBNVPFT5.js} +157 -158
- package/dist/{menu-52PXC5PL.js → menu-5JZMGNOT.js} +2 -1
- package/dist/{menu-YIGMPFUP.js → menu-O3G4LM5O.js} +3 -2
- package/dist/next/index.js +6 -5
- package/dist/react/context-menu.d.ts +1 -1
- package/dist/react/context-menu.js +2 -1
- package/dist/react/image.js +1 -1
- package/dist/react/index.js +6 -5
- package/dist/react/input.js +1 -1
- package/dist/react/select.d.ts +6 -1
- package/dist/react/select.js +2 -1
- package/dist/react/video.js +1 -1
- package/dist/react-router/index.js +6 -5
- package/dist/viewer-C323RYNZ.js +3 -0
- package/package.json +1 -1
- package/recipes/color/styles.css +12 -7
- package/recipes/context-menu/styles.css +9 -2
- package/recipes/select/styles.css +24 -7
- package/registry.json +98 -5
- package/src/core/floating.ts +71 -0
- package/src/react/context-menu/root.tsx +30 -15
- package/src/react/context-menu/styles.ts +9 -2
- package/src/react/floating.ts +185 -0
- package/src/react/input/color-styles.ts +12 -7
- package/src/react/input/color.tsx +30 -27
- package/src/react/select/context.ts +5 -0
- package/src/react/select/root.tsx +33 -21
- package/src/react/select/styles.ts +24 -7
- package/dist/viewer-N5WLFB24.js +0 -3
package/dist/react/select.d.ts
CHANGED
|
@@ -38,6 +38,11 @@ interface SelectContextValue {
|
|
|
38
38
|
optionId: (index: number) => string;
|
|
39
39
|
triggerRef: RefObject<HTMLButtonElement | null>;
|
|
40
40
|
fieldRef: RefObject<HTMLInputElement | null>;
|
|
41
|
+
/**
|
|
42
|
+
* The panel. Portaled out of the root, so "inside the select" is the root OR this - a
|
|
43
|
+
* press or a focus check against the root alone would call every option outside.
|
|
44
|
+
*/
|
|
45
|
+
contentRef: RefObject<HTMLDivElement | null>;
|
|
41
46
|
/** Close and put focus back where it came from - the trigger, always. */
|
|
42
47
|
close: () => void;
|
|
43
48
|
/** The keys the list answers to, shared by the trigger and the search field. */
|
|
@@ -167,7 +172,7 @@ declare function SelectOptionRow({ option, index, children, ...props }: SelectOp
|
|
|
167
172
|
* override a selector for the rest. The sheet is PREPENDED to `<head>`, so anything the
|
|
168
173
|
* document already has wins on source order at equal specificity.
|
|
169
174
|
*/
|
|
170
|
-
declare const SELECT_STYLES = "\n:root {\n --enigma-select-bg: #171717;\n --enigma-select-border: #404040;\n --enigma-select-border-focus: #a3a3a3;\n --enigma-select-text: #f5f5f5;\n --enigma-select-muted: #a3a3a3;\n --enigma-select-radius: 0.5rem;\n --enigma-select-font-size: 0.875rem;\n --enigma-select-padding: 0.5rem 0.75rem;\n\n --enigma-select-panel-bg: #1c1c1c;\n --enigma-select-panel-border: #333333;\n --enigma-select-panel-radius: 0.625rem;\n --enigma-select-panel-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);\n --enigma-select-panel-width: 100%;\n --enigma-select-list-height: 15rem;\n\n --enigma-select-active-bg: #2a2a2a;\n --enigma-select-accent: #fbbf24;\n --enigma-select-tag-bg: #2a2a2a;\n}\n\n@media (prefers-color-scheme: light) {\n :root {\n --enigma-select-bg: #ffffff;\n --enigma-select-border: #d4d4d4;\n --enigma-select-border-focus: #737373;\n --enigma-select-text: #171717;\n --enigma-select-muted: #737373;\n --enigma-select-panel-bg: #ffffff;\n --enigma-select-panel-border: #e5e5e5;\n --enigma-select-panel-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);\n --enigma-select-active-bg: #f5f5f5;\n --enigma-select-accent: #b45309;\n --enigma-select-tag-bg: #f0f0f0;\n }\n}\n\n[data-enigma-select-root] { position: relative; display: inline-block; min-width: 0; }\n\n[data-enigma-select-trigger] {\n display: flex; align-items: center; gap: 0.5rem;\n width: 100%; min-width: 0; box-sizing: border-box;\n padding: var(--enigma-select-padding);\n font: inherit; font-size: var(--enigma-select-font-size); text-align: left;\n color: var(--enigma-select-text); background: var(--enigma-select-bg);\n border: 1px solid var(--enigma-select-border); border-radius: var(--enigma-select-radius);\n cursor: pointer;\n}\n[data-enigma-select-trigger]:focus-visible { outline: 2px solid var(--enigma-select-border-focus); outline-offset: 1px; }\n[data-enigma-select-trigger][data-open] { border-color: var(--enigma-select-border-focus); }\n[data-enigma-select-trigger]:disabled,\n[data-enigma-select-trigger][aria-disabled=\"true\"] { opacity: 0.5; cursor: not-allowed; }\n/* Nothing to choose, or nothing yet: the control reads as unavailable and the caret fades,\n because a caret at full strength promises a panel that will never open. Not the disabled\n look, which would say the field is off rather than that the list is empty. */\n[data-enigma-select-trigger][data-empty],\n[data-enigma-select-trigger][data-loading] { opacity: 1; cursor: default; }\n[data-enigma-select-trigger][data-empty] [data-enigma-select-caret],\n[data-enigma-select-trigger][data-loading] [data-enigma-select-caret] { opacity: 0.25; }\n\n/* The indicator: one slot, holding the caret and the \u00D7 stacked in the same cell. Side by\n side they would be two targets a pixel apart - one of which throws away your choice - and\n the pair would change the trigger's width the moment a value appeared. */\n[data-enigma-select-indicator] {\n display: grid; place-items: center; flex: none;\n margin-left: auto; width: 1rem; height: 1rem;\n}\n[data-enigma-select-indicator] > * { grid-area: 1 / 1; }\n\n/* The caret, drawn rather than loaded: an icon font or an SVG file is a request, and this\n is two borders. */\n[data-enigma-select-caret] {\n width: 0.4rem; height: 0.4rem;\n /* Decoration, and it must not be the hit target: the rotation gives it a stacking\n context, which paints it OVER the \u00D7 sharing its cell and swallows the click. */\n pointer-events: none;\n border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;\n transform: translateY(-0.1rem) rotate(45deg);\n opacity: 0.6; transition: transform 120ms ease-out, opacity 120ms ease-out;\n}\n[data-enigma-select-trigger][data-open] [data-enigma-select-caret] { transform: translateY(0.1rem) rotate(225deg); }\n\n[data-enigma-select-value] {\n display: flex; align-items: center; gap: 0.375rem;\n /* min-width: 0 is what lets the ellipsis happen: a flex item's default minimum is its\n content, so without it a long label pushes the caret out of the button. */\n min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;\n}\n[data-enigma-select-value][data-placeholder] { color: var(--enigma-select-muted); }\n[data-enigma-select-value][data-tags] { flex-wrap: wrap; white-space: normal; }\n\n[data-enigma-select-tag] {\n display: inline-flex; align-items: center; gap: 0.25rem;\n padding: 0.125rem 0.25rem 0.125rem 0.5rem;\n font-size: 0.8125rem; line-height: 1.4;\n background: var(--enigma-select-tag-bg); border-radius: 999px;\n}\n[data-enigma-select-tag][data-rest] { padding: 0.125rem 0.5rem; color: var(--enigma-select-muted); }\n\n[data-enigma-select-tag-remove] {\n display: grid; place-items: center;\n width: 1rem; height: 1rem; border-radius: 999px;\n color: var(--enigma-select-muted); font-size: 0.875rem; line-height: 1; cursor: pointer;\n}\n[data-enigma-select-tag-remove]:hover { color: var(--enigma-select-text); background: var(--enigma-select-active-bg); }\n\n/* The \u00D7, in the caret's place and only while the control is under the pointer or holds\n focus. \":focus\" and not \":focus-visible\": a tap focuses the button, and on a touch screen\n there is no hover to reveal it with. */\n[data-enigma-select-clear] {\n display: grid; place-items: center;\n width: 1rem; height: 1rem; border-radius: 999px;\n color: var(--enigma-select-muted); font-size: 0.875rem; line-height: 1; cursor: pointer;\n opacity: 0; pointer-events: none; transition: opacity 120ms ease-out;\n}\n[data-enigma-select-trigger]:hover [data-enigma-select-clear],\n[data-enigma-select-trigger]:focus [data-enigma-select-clear] { opacity: 1; pointer-events: auto; }\n/* Scoped to a trigger that HAS a \u00D7 - otherwise hovering a select with nothing to clear\n would hide its caret and leave an empty slot. */\n[data-enigma-select-trigger][data-clearable]:hover [data-enigma-select-caret],\n[data-enigma-select-trigger][data-clearable]:focus [data-enigma-select-caret] { opacity: 0; }\n[data-enigma-select-clear]:hover { color: var(--enigma-select-text); background: var(--enigma-select-active-bg); }\n\n[data-enigma-select-content] {\n position: absolute; z-index: 50;\n top: calc(100% + 0.25rem); left: 0;\n min-width: var(--enigma-select-panel-width); box-sizing: border-box;\n padding: 0.25rem;\n background: var(--enigma-select-panel-bg);\n border: 1px solid var(--enigma-select-panel-border);\n border-radius: var(--enigma-select-panel-radius);\n box-shadow: var(--enigma-select-panel-shadow);\n animation: enigma-select-in 120ms ease-out;\n /* A bound, because the content is arbitrary: a long option label or a query typed into\n the filter would otherwise widen the panel with it. min-width still wins for a\n trigger wider than this, which is what it is for. */\n max-width: min(28rem, calc(100vw - 2rem));\n}\n/* Opening upwards is not a variant, it is the same panel measured against the window: near\n the bottom of the screen the list would otherwise be unreachable. */\n[data-enigma-select-content][data-side=\"top\"] { top: auto; bottom: calc(100% + 0.25rem); }\n[data-enigma-select-content][data-state=\"closed\"] { animation: enigma-select-out 120ms ease-in forwards; pointer-events: none; }\n\n@keyframes enigma-select-in {\n from { opacity: 0; transform: translateY(-0.25rem); }\n to { opacity: 1; transform: none; }\n}\n@keyframes enigma-select-out {\n from { opacity: 1; }\n to { opacity: 0; transform: translateY(-0.125rem); }\n}\n@media (prefers-reduced-motion: reduce) {\n [data-enigma-select-content],\n [data-enigma-select-content][data-state=\"closed\"] { animation: none; }\n [data-enigma-select-trigger]::after { transition: none; }\n}\n\n[data-enigma-select-search] {\n width: 100%; box-sizing: border-box;\n padding: 0.375rem 0.5rem 0.5rem;\n font: inherit; font-size: var(--enigma-select-font-size);\n color: var(--enigma-select-text); background: transparent;\n border: 0; border-bottom: 1px solid var(--enigma-select-panel-border);\n outline: none;\n}\n/* WebKit's own clear cross, gone: it is drawn on no other engine, so leaving it in gives\n half the visitors a control the other half never sees. */\n[data-enigma-select-search]::-webkit-search-cancel-button,\n[data-enigma-select-search]::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }\n\n[data-enigma-select-list] {\n max-height: var(--enigma-select-list-height);\n margin-top: 0.25rem;\n overflow-y: auto; overscroll-behavior: contain;\n}\n\n[data-enigma-select-group-label] {\n margin: 0.375rem 0 0.125rem; padding: 0 0.5rem;\n font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;\n color: var(--enigma-select-muted);\n}\n\n[data-enigma-select-option] {\n display: flex; align-items: center; gap: 0.5rem;\n padding: 0.4375rem 0.5rem; border-radius: 0.375rem;\n font-size: var(--enigma-select-font-size); color: var(--enigma-select-text);\n cursor: pointer;\n}\n[data-enigma-select-option][data-active] { background: var(--enigma-select-active-bg); }\n[data-enigma-select-option][data-disabled] { opacity: 0.45; cursor: not-allowed; }\n\n[data-enigma-select-option-text] { display: grid; min-width: 0; }\n[data-enigma-select-option-label] { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }\n[data-enigma-select-option-description] {\n font-size: 0.75rem; color: var(--enigma-select-muted);\n overflow: hidden; white-space: nowrap; text-overflow: ellipsis;\n}\n\n[data-enigma-select-icon] { display: inline-flex; align-items: center; flex: none; max-height: 1.25em; line-height: 0; }\n\n/* The check, drawn with two borders and shown only where it belongs. Reserved space either\n way, so choosing a row does not shift the label under the pointer. */\n[data-enigma-select-check] {\n flex: none; margin-left: auto;\n width: 0.35rem; height: 0.6rem;\n border-right: 2px solid var(--enigma-select-accent); border-bottom: 2px solid var(--enigma-select-accent);\n transform: rotate(45deg) translate(-0.05rem, -0.05rem);\n opacity: 0;\n}\n[data-enigma-select-option][data-selected] [data-enigma-select-check] { opacity: 1; }\n\n/* The end-of-window marker: one pixel, so it has a box to be scrolled into. */\n[data-enigma-select-more] { height: 1px; }\n\n[data-enigma-select-empty] {\n margin: 0; padding: 0.75rem 0.5rem;\n font-size: var(--enigma-select-font-size); color: var(--enigma-select-muted);\n /* The quoted query is cut in the text as well, but a pasted string with no spaces has\n no break opportunity at all - so this is what stops it stretching the panel. */\n overflow-wrap: anywhere;\n}\n";
|
|
175
|
+
declare const SELECT_STYLES = "\n:root {\n --enigma-select-bg: #171717;\n --enigma-select-border: #404040;\n --enigma-select-border-focus: #a3a3a3;\n --enigma-select-text: #f5f5f5;\n --enigma-select-muted: #a3a3a3;\n --enigma-select-radius: 0.5rem;\n --enigma-select-font-size: 0.875rem;\n --enigma-select-padding: 0.5rem 0.75rem;\n\n --enigma-select-panel-bg: #1c1c1c;\n --enigma-select-panel-border: #333333;\n --enigma-select-panel-radius: 0.625rem;\n --enigma-select-panel-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);\n --enigma-select-list-height: 15rem;\n\n --enigma-select-active-bg: #2a2a2a;\n --enigma-select-accent: #fbbf24;\n --enigma-select-tag-bg: #2a2a2a;\n\n /* The layer every portaled popup shares: above the lightbox (9999), because a popup is\n opened FROM whatever is on top. The select, the colour picker and the context menu\n each declare it with the same value, so overriding it once moves all three. Here and\n not in the light-scheme block, or half the readers get an invalid z-index. */\n --enigma-floating-z: 10000;\n}\n\n@media (prefers-color-scheme: light) {\n :root {\n --enigma-select-bg: #ffffff;\n --enigma-select-border: #d4d4d4;\n --enigma-select-border-focus: #737373;\n --enigma-select-text: #171717;\n --enigma-select-muted: #737373;\n --enigma-select-panel-bg: #ffffff;\n --enigma-select-panel-border: #e5e5e5;\n --enigma-select-panel-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);\n --enigma-select-active-bg: #f5f5f5;\n --enigma-select-accent: #b45309;\n --enigma-select-tag-bg: #f0f0f0;\n }\n}\n\n[data-enigma-select-root] { position: relative; display: inline-block; min-width: 0; }\n\n[data-enigma-select-trigger] {\n display: flex; align-items: center; gap: 0.5rem;\n width: 100%; min-width: 0; box-sizing: border-box;\n padding: var(--enigma-select-padding);\n font: inherit; font-size: var(--enigma-select-font-size); text-align: left;\n color: var(--enigma-select-text); background: var(--enigma-select-bg);\n border: 1px solid var(--enigma-select-border); border-radius: var(--enigma-select-radius);\n cursor: pointer;\n}\n[data-enigma-select-trigger]:focus-visible { outline: 2px solid var(--enigma-select-border-focus); outline-offset: 1px; }\n[data-enigma-select-trigger][data-open] { border-color: var(--enigma-select-border-focus); }\n[data-enigma-select-trigger]:disabled,\n[data-enigma-select-trigger][aria-disabled=\"true\"] { opacity: 0.5; cursor: not-allowed; }\n/* Nothing to choose, or nothing yet: the control reads as unavailable and the caret fades,\n because a caret at full strength promises a panel that will never open. Not the disabled\n look, which would say the field is off rather than that the list is empty. */\n[data-enigma-select-trigger][data-empty],\n[data-enigma-select-trigger][data-loading] { opacity: 1; cursor: default; }\n[data-enigma-select-trigger][data-empty] [data-enigma-select-caret],\n[data-enigma-select-trigger][data-loading] [data-enigma-select-caret] { opacity: 0.25; }\n\n/* The indicator: one slot, holding the caret and the \u00D7 stacked in the same cell. Side by\n side they would be two targets a pixel apart - one of which throws away your choice - and\n the pair would change the trigger's width the moment a value appeared. */\n[data-enigma-select-indicator] {\n display: grid; place-items: center; flex: none;\n margin-left: auto; width: 1rem; height: 1rem;\n}\n[data-enigma-select-indicator] > * { grid-area: 1 / 1; }\n\n/* The caret, drawn rather than loaded: an icon font or an SVG file is a request, and this\n is two borders. */\n[data-enigma-select-caret] {\n width: 0.4rem; height: 0.4rem;\n /* Decoration, and it must not be the hit target: the rotation gives it a stacking\n context, which paints it OVER the \u00D7 sharing its cell and swallows the click. */\n pointer-events: none;\n border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;\n transform: translateY(-0.1rem) rotate(45deg);\n opacity: 0.6; transition: transform 120ms ease-out, opacity 120ms ease-out;\n}\n[data-enigma-select-trigger][data-open] [data-enigma-select-caret] { transform: translateY(0.1rem) rotate(225deg); }\n\n[data-enigma-select-value] {\n display: flex; align-items: center; gap: 0.375rem;\n /* min-width: 0 is what lets the ellipsis happen: a flex item's default minimum is its\n content, so without it a long label pushes the caret out of the button. */\n min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;\n}\n[data-enigma-select-value][data-placeholder] { color: var(--enigma-select-muted); }\n[data-enigma-select-value][data-tags] { flex-wrap: wrap; white-space: normal; }\n\n[data-enigma-select-tag] {\n display: inline-flex; align-items: center; gap: 0.25rem;\n padding: 0.125rem 0.25rem 0.125rem 0.5rem;\n font-size: 0.8125rem; line-height: 1.4;\n background: var(--enigma-select-tag-bg); border-radius: 999px;\n}\n[data-enigma-select-tag][data-rest] { padding: 0.125rem 0.5rem; color: var(--enigma-select-muted); }\n\n[data-enigma-select-tag-remove] {\n display: grid; place-items: center;\n width: 1rem; height: 1rem; border-radius: 999px;\n color: var(--enigma-select-muted); font-size: 0.875rem; line-height: 1; cursor: pointer;\n}\n[data-enigma-select-tag-remove]:hover { color: var(--enigma-select-text); background: var(--enigma-select-active-bg); }\n\n/* The \u00D7, in the caret's place and only while the control is under the pointer or holds\n focus. \":focus\" and not \":focus-visible\": a tap focuses the button, and on a touch screen\n there is no hover to reveal it with. */\n[data-enigma-select-clear] {\n display: grid; place-items: center;\n width: 1rem; height: 1rem; border-radius: 999px;\n color: var(--enigma-select-muted); font-size: 0.875rem; line-height: 1; cursor: pointer;\n opacity: 0; pointer-events: none; transition: opacity 120ms ease-out;\n}\n[data-enigma-select-trigger]:hover [data-enigma-select-clear],\n[data-enigma-select-trigger]:focus [data-enigma-select-clear] { opacity: 1; pointer-events: auto; }\n/* Scoped to a trigger that HAS a \u00D7 - otherwise hovering a select with nothing to clear\n would hide its caret and leave an empty slot. */\n[data-enigma-select-trigger][data-clearable]:hover [data-enigma-select-caret],\n[data-enigma-select-trigger][data-clearable]:focus [data-enigma-select-caret] { opacity: 0; }\n[data-enigma-select-clear]:hover { color: var(--enigma-select-text); background: var(--enigma-select-active-bg); }\n\n/* Portaled to <body> and placed in viewport coordinates against the trigger (see\n react/floating.ts), so no ancestor's overflow can clip it and no dialog can stack over it.\n left, top, max-height and data-side are written by the placement: below the trigger,\n flipped above when there is more room there, and capped to the room it has. The column\n is what makes the cap shrink the LIST, which scrolls, instead of cutting off the panel. */\n[data-enigma-select-content] {\n position: fixed; z-index: var(--enigma-floating-z);\n display: flex; flex-direction: column;\n /* As wide as the trigger, measured - 100% would now be a percentage of the window - and\n wider only for content that cannot shrink. min-content is what keeps the rest at the\n trigger's width: fixed, the panel's room is the whole window, and a long label or a\n long \"Nothing matches\" would otherwise widen it to max-width. --enigma-select-panel-width\n overrides the trigger's width. */\n width: min-content;\n min-width: var(--enigma-select-panel-width, var(--enigma-anchor-width, auto)); box-sizing: border-box;\n padding: 0.25rem;\n color: var(--enigma-select-text);\n font-size: var(--enigma-select-font-size);\n background: var(--enigma-select-panel-bg);\n border: 1px solid var(--enigma-select-panel-border);\n border-radius: var(--enigma-select-panel-radius);\n box-shadow: var(--enigma-select-panel-shadow);\n animation: enigma-select-in 120ms ease-out;\n /* A bound, because the content is arbitrary: a long option label or a query typed into\n the filter would otherwise widen the panel with it. min-width still wins for a\n trigger wider than this, which is what it is for. */\n max-width: min(28rem, calc(100vw - 2rem));\n}\n[data-enigma-select-content][data-state=\"closed\"] { animation: enigma-select-out 120ms ease-in forwards; pointer-events: none; }\n\n@keyframes enigma-select-in {\n from { opacity: 0; transform: translateY(-0.25rem); }\n to { opacity: 1; transform: none; }\n}\n@keyframes enigma-select-out {\n from { opacity: 1; }\n to { opacity: 0; transform: translateY(-0.125rem); }\n}\n@media (prefers-reduced-motion: reduce) {\n [data-enigma-select-content],\n [data-enigma-select-content][data-state=\"closed\"] { animation: none; }\n [data-enigma-select-trigger]::after { transition: none; }\n}\n\n[data-enigma-select-search] {\n width: 100%; box-sizing: border-box;\n padding: 0.375rem 0.5rem 0.5rem;\n font: inherit; font-size: var(--enigma-select-font-size);\n color: var(--enigma-select-text); background: transparent;\n border: 0; border-bottom: 1px solid var(--enigma-select-panel-border);\n outline: none;\n}\n/* WebKit's own clear cross, gone: it is drawn on no other engine, so leaving it in gives\n half the visitors a control the other half never sees. */\n[data-enigma-select-search]::-webkit-search-cancel-button,\n[data-enigma-select-search]::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }\n\n[data-enigma-select-list] {\n max-height: var(--enigma-select-list-height);\n /* Allowed below its content, so the panel's placement cap shrinks it and it scrolls. */\n min-height: 0; flex: 0 1 auto;\n margin-top: 0.25rem;\n overflow-y: auto; overscroll-behavior: contain;\n}\n\n[data-enigma-select-group-label] {\n margin: 0.375rem 0 0.125rem; padding: 0 0.5rem;\n font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;\n color: var(--enigma-select-muted);\n}\n\n[data-enigma-select-option] {\n display: flex; align-items: center; gap: 0.5rem;\n padding: 0.4375rem 0.5rem; border-radius: 0.375rem;\n font-size: var(--enigma-select-font-size); color: var(--enigma-select-text);\n cursor: pointer;\n}\n[data-enigma-select-option][data-active] { background: var(--enigma-select-active-bg); }\n[data-enigma-select-option][data-disabled] { opacity: 0.45; cursor: not-allowed; }\n\n[data-enigma-select-option-text] { display: grid; min-width: 0; }\n[data-enigma-select-option-label] { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }\n[data-enigma-select-option-description] {\n font-size: 0.75rem; color: var(--enigma-select-muted);\n overflow: hidden; white-space: nowrap; text-overflow: ellipsis;\n}\n\n[data-enigma-select-icon] { display: inline-flex; align-items: center; flex: none; max-height: 1.25em; line-height: 0; }\n\n/* The check, drawn with two borders and shown only where it belongs. Reserved space either\n way, so choosing a row does not shift the label under the pointer. */\n[data-enigma-select-check] {\n flex: none; margin-left: auto;\n width: 0.35rem; height: 0.6rem;\n border-right: 2px solid var(--enigma-select-accent); border-bottom: 2px solid var(--enigma-select-accent);\n transform: rotate(45deg) translate(-0.05rem, -0.05rem);\n opacity: 0;\n}\n[data-enigma-select-option][data-selected] [data-enigma-select-check] { opacity: 1; }\n\n/* The end-of-window marker: one pixel, so it has a box to be scrolled into. */\n[data-enigma-select-more] { height: 1px; }\n\n[data-enigma-select-empty] {\n margin: 0; padding: 0.75rem 0.5rem;\n font-size: var(--enigma-select-font-size); color: var(--enigma-select-muted);\n /* The quoted query is cut in the text as well, but a pasted string with no spaces has\n no break opportunity at all - so this is what stops it stretching the panel. */\n overflow-wrap: anywhere;\n}\n";
|
|
171
176
|
|
|
172
177
|
type SelectProps = SelectRootProps & {
|
|
173
178
|
/** What the trigger says with nothing chosen. */
|
package/dist/react/select.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
export { SELECT_STYLES, Select, SelectContent, SelectList, SelectOptionRow, SelectRoot, SelectSearch, SelectTrigger, SelectValue, useSelectContext } from '../chunk-
|
|
2
|
+
export { SELECT_STYLES, Select, SelectContent, SelectList, SelectOptionRow, SelectRoot, SelectSearch, SelectTrigger, SelectValue, useSelectContext } from '../chunk-6WIGVIQF.js';
|
|
3
|
+
import '../chunk-RXEO4WPI.js';
|
|
3
4
|
export { SELECT_SEARCH_KEYS, createSelect } from '../chunk-NONGREXC.js';
|
|
4
5
|
import '../chunk-4VUHQFAT.js';
|
|
5
6
|
import '../chunk-HS3X3XCW.js';
|
package/dist/react/video.js
CHANGED
|
@@ -4,11 +4,11 @@ export { useSearch } from '../chunk-45UHLZYT.js';
|
|
|
4
4
|
export { useNetworkState } from '../chunk-S653GLSF.js';
|
|
5
5
|
export { RelativeTime } from '../chunk-OCMI7R6H.js';
|
|
6
6
|
export { PasswordStrength } from '../chunk-XQHCZAPJ.js';
|
|
7
|
-
export { ContextMenu, ContextMenuContent, ContextMenuList, ContextMenuPanel, ContextMenuRoot, ContextMenuRow, ContextMenuSearch, ContextMenuTrigger, useContextMenuContext } from '../chunk-
|
|
7
|
+
export { ContextMenu, ContextMenuContent, ContextMenuList, ContextMenuPanel, ContextMenuRoot, ContextMenuRow, ContextMenuSearch, ContextMenuTrigger, useContextMenuContext } from '../chunk-RRPWJAIJ.js';
|
|
8
8
|
export { SelectionList, useSelection } from '../chunk-N6PDHMAX.js';
|
|
9
|
-
export { Image } from '../chunk-
|
|
9
|
+
export { Image } from '../chunk-IQMHDRJF.js';
|
|
10
10
|
import '../chunk-EYZ366LP.js';
|
|
11
|
-
export { Video } from '../chunk-
|
|
11
|
+
export { Video } from '../chunk-JPBVKXOL.js';
|
|
12
12
|
import { Button } from '../chunk-6BGBYUSZ.js';
|
|
13
13
|
export { getLinkComponent, setLinkComponent, useButton } from '../chunk-6BGBYUSZ.js';
|
|
14
14
|
export { Flag } from '../chunk-NPHRS2Q7.js';
|
|
@@ -27,11 +27,12 @@ import '../chunk-MMQPZGSU.js';
|
|
|
27
27
|
export { configureFlags, flagAttributes, flagName, flagSrc, flagView, normalizeFlagCode } from '../chunk-LMZJBOZG.js';
|
|
28
28
|
import '../chunk-SNYUBXWQ.js';
|
|
29
29
|
import '../chunk-UOSSNUSC.js';
|
|
30
|
-
export { Input } from '../chunk-
|
|
30
|
+
export { Input } from '../chunk-YHH5U2NW.js';
|
|
31
31
|
import '../chunk-KVLSOQTI.js';
|
|
32
32
|
import '../chunk-F25CGNQC.js';
|
|
33
33
|
export { PaletteContent, PaletteField, PaletteFooter, PaletteItem, PaletteList, PaletteRoot, PaletteTrigger, SearchPalette, usePaletteContext } from '../chunk-FGBIZDV2.js';
|
|
34
|
-
export { Select, SelectContent, SelectList, SelectOptionRow, SelectRoot, SelectSearch, SelectTrigger, SelectValue, useSelectContext } from '../chunk-
|
|
34
|
+
export { Select, SelectContent, SelectList, SelectOptionRow, SelectRoot, SelectSearch, SelectTrigger, SelectValue, useSelectContext } from '../chunk-6WIGVIQF.js';
|
|
35
|
+
import '../chunk-RXEO4WPI.js';
|
|
35
36
|
export { createSelect } from '../chunk-NONGREXC.js';
|
|
36
37
|
export { isApplePlatform, matchesShortcut, parseShortcut, shortcutText, shortcutTokens } from '../chunk-4VUHQFAT.js';
|
|
37
38
|
export { Slot, mergeSlotProps } from '../chunk-HS3X3XCW.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enigmax/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Headless components: the behaviour, the timing and the accessibility, with no styles of their own. Framework-agnostic core plus thin adapters.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
package/recipes/color/styles.css
CHANGED
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
on, which is why only one colour is a property. */
|
|
25
25
|
--enigma-color-checker: rgba(255, 255, 255, 0.22);
|
|
26
26
|
--enigma-color-checker-size: 0.5rem;
|
|
27
|
+
|
|
28
|
+
/* The layer every portaled popup shares, declared with the same value by the select and
|
|
29
|
+
the context menu: above the lightbox (9999), because a popup is opened FROM whatever
|
|
30
|
+
is on top. Not in the light-scheme block, or half the readers get no z-index. */
|
|
31
|
+
--enigma-floating-z: 10000;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
@media (prefers-color-scheme: light) {
|
|
@@ -40,9 +45,7 @@
|
|
|
40
45
|
}
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
/* The anchor the panel
|
|
44
|
-
positioning context so the panel needs no portal and no scroll listener: it moves with
|
|
45
|
-
the field because it is INSIDE it. */
|
|
48
|
+
/* The anchor the panel is placed against. Inline so it sits in the field's own row. */
|
|
46
49
|
[data-enigma-color] { position: relative; display: inline-flex; flex: none; }
|
|
47
50
|
|
|
48
51
|
[data-enigma-color-swatch] {
|
|
@@ -66,8 +69,13 @@
|
|
|
66
69
|
rather than the last good colour: a stale swatch says the value is fine when it is not. */
|
|
67
70
|
[data-enigma-color-swatch][data-invalid] [data-enigma-color-fill] { background: none; }
|
|
68
71
|
|
|
72
|
+
/* Portaled and placed in viewport coordinates against the swatch (see react/floating.ts),
|
|
73
|
+
so a dialog or a card with overflow: hidden cannot cut it off. left, top, max-height and
|
|
74
|
+
data-side are written by the placement: below, flipped above when there is more room
|
|
75
|
+
there, and capped to the room it has - scrolling inside rather than off the screen. */
|
|
69
76
|
[data-enigma-color-panel] {
|
|
70
|
-
position:
|
|
77
|
+
position: fixed; z-index: var(--enigma-floating-z);
|
|
78
|
+
overflow-y: auto; overscroll-behavior: contain;
|
|
71
79
|
display: grid; gap: 0.625rem;
|
|
72
80
|
box-sizing: border-box; width: var(--enigma-color-panel-width); padding: 0.625rem;
|
|
73
81
|
color: var(--enigma-color-text); background: var(--enigma-color-panel-bg);
|
|
@@ -75,9 +83,6 @@
|
|
|
75
83
|
border-radius: var(--enigma-color-panel-radius);
|
|
76
84
|
box-shadow: var(--enigma-color-panel-shadow);
|
|
77
85
|
}
|
|
78
|
-
/* Flipped when the field sits near the bottom of the window. Without it the panel opens off
|
|
79
|
-
the screen and the controls are unreachable, which is the same defect the select fixes. */
|
|
80
|
-
[data-enigma-color-panel][data-side="top"] { top: auto; bottom: calc(100% + 0.375rem); }
|
|
81
86
|
|
|
82
87
|
[data-enigma-color-area] {
|
|
83
88
|
position: relative; height: var(--enigma-color-area-height);
|
|
@@ -26,8 +26,11 @@
|
|
|
26
26
|
top: the image viewer's own three-dot menu was portalled to the same body and painted
|
|
27
27
|
underneath it, so its rows could be neither seen nor pressed. A menu is the topmost
|
|
28
28
|
transient surface on a page, and this is the number that says so. It belongs here and
|
|
29
|
-
not in the light-scheme block, or half the readers get an invalid z-index.
|
|
30
|
-
|
|
29
|
+
not in the light-scheme block, or half the readers get an invalid z-index.
|
|
30
|
+
--enigma-floating-z is the layer the select and the colour picker share, declared with
|
|
31
|
+
the same value in their sheets, so overriding it once moves every popup together. */
|
|
32
|
+
--enigma-floating-z: 10000;
|
|
33
|
+
--enigma-menu-z: var(--enigma-floating-z);
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
@media (prefers-color-scheme: light) {
|
|
@@ -49,6 +52,9 @@
|
|
|
49
52
|
ancestor cannot drag it away from the place it was opened. */
|
|
50
53
|
[data-enigma-menu-panel] {
|
|
51
54
|
position: fixed; z-index: var(--enigma-menu-z);
|
|
55
|
+
/* A column, so the max-height the placement writes in a short window shrinks the LIST,
|
|
56
|
+
which scrolls, rather than letting rows spill past the panel's edge. */
|
|
57
|
+
display: flex; flex-direction: column;
|
|
52
58
|
box-sizing: border-box;
|
|
53
59
|
min-width: var(--enigma-menu-min-width);
|
|
54
60
|
max-width: min(22rem, calc(100vw - 1rem));
|
|
@@ -103,6 +109,7 @@
|
|
|
103
109
|
|
|
104
110
|
[data-enigma-menu-list] {
|
|
105
111
|
max-height: var(--enigma-menu-max-height);
|
|
112
|
+
min-height: 0; flex: 0 1 auto;
|
|
106
113
|
overflow-y: auto; overscroll-behavior: contain;
|
|
107
114
|
}
|
|
108
115
|
|
|
@@ -20,12 +20,17 @@
|
|
|
20
20
|
--enigma-select-panel-border: #333333;
|
|
21
21
|
--enigma-select-panel-radius: 0.625rem;
|
|
22
22
|
--enigma-select-panel-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
|
23
|
-
--enigma-select-panel-width: 100%;
|
|
24
23
|
--enigma-select-list-height: 15rem;
|
|
25
24
|
|
|
26
25
|
--enigma-select-active-bg: #2a2a2a;
|
|
27
26
|
--enigma-select-accent: #fbbf24;
|
|
28
27
|
--enigma-select-tag-bg: #2a2a2a;
|
|
28
|
+
|
|
29
|
+
/* The layer every portaled popup shares: above the lightbox (9999), because a popup is
|
|
30
|
+
opened FROM whatever is on top. The select, the colour picker and the context menu
|
|
31
|
+
each declare it with the same value, so overriding it once moves all three. Here and
|
|
32
|
+
not in the light-scheme block, or half the readers get an invalid z-index. */
|
|
33
|
+
--enigma-floating-z: 10000;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
@media (prefers-color-scheme: light) {
|
|
@@ -130,11 +135,24 @@
|
|
|
130
135
|
[data-enigma-select-trigger][data-clearable]:focus [data-enigma-select-caret] { opacity: 0; }
|
|
131
136
|
[data-enigma-select-clear]:hover { color: var(--enigma-select-text); background: var(--enigma-select-active-bg); }
|
|
132
137
|
|
|
138
|
+
/* Portaled to <body> and placed in viewport coordinates against the trigger (see
|
|
139
|
+
react/floating.ts), so no ancestor's overflow can clip it and no dialog can stack over it.
|
|
140
|
+
left, top, max-height and data-side are written by the placement: below the trigger,
|
|
141
|
+
flipped above when there is more room there, and capped to the room it has. The column
|
|
142
|
+
is what makes the cap shrink the LIST, which scrolls, instead of cutting off the panel. */
|
|
133
143
|
[data-enigma-select-content] {
|
|
134
|
-
position:
|
|
135
|
-
|
|
136
|
-
|
|
144
|
+
position: fixed; z-index: var(--enigma-floating-z);
|
|
145
|
+
display: flex; flex-direction: column;
|
|
146
|
+
/* As wide as the trigger, measured - 100% would now be a percentage of the window - and
|
|
147
|
+
wider only for content that cannot shrink. min-content is what keeps the rest at the
|
|
148
|
+
trigger's width: fixed, the panel's room is the whole window, and a long label or a
|
|
149
|
+
long "Nothing matches" would otherwise widen it to max-width. --enigma-select-panel-width
|
|
150
|
+
overrides the trigger's width. */
|
|
151
|
+
width: min-content;
|
|
152
|
+
min-width: var(--enigma-select-panel-width, var(--enigma-anchor-width, auto)); box-sizing: border-box;
|
|
137
153
|
padding: 0.25rem;
|
|
154
|
+
color: var(--enigma-select-text);
|
|
155
|
+
font-size: var(--enigma-select-font-size);
|
|
138
156
|
background: var(--enigma-select-panel-bg);
|
|
139
157
|
border: 1px solid var(--enigma-select-panel-border);
|
|
140
158
|
border-radius: var(--enigma-select-panel-radius);
|
|
@@ -145,9 +163,6 @@
|
|
|
145
163
|
trigger wider than this, which is what it is for. */
|
|
146
164
|
max-width: min(28rem, calc(100vw - 2rem));
|
|
147
165
|
}
|
|
148
|
-
/* Opening upwards is not a variant, it is the same panel measured against the window: near
|
|
149
|
-
the bottom of the screen the list would otherwise be unreachable. */
|
|
150
|
-
[data-enigma-select-content][data-side="top"] { top: auto; bottom: calc(100% + 0.25rem); }
|
|
151
166
|
[data-enigma-select-content][data-state="closed"] { animation: enigma-select-out 120ms ease-in forwards; pointer-events: none; }
|
|
152
167
|
|
|
153
168
|
@keyframes enigma-select-in {
|
|
@@ -179,6 +194,8 @@
|
|
|
179
194
|
|
|
180
195
|
[data-enigma-select-list] {
|
|
181
196
|
max-height: var(--enigma-select-list-height);
|
|
197
|
+
/* Allowed below its content, so the panel's placement cap shrinks it and it scrolls. */
|
|
198
|
+
min-height: 0; flex: 0 1 auto;
|
|
182
199
|
margin-top: 0.25rem;
|
|
183
200
|
overflow-y: auto; overscroll-behavior: contain;
|
|
184
201
|
}
|
package/registry.json
CHANGED
|
@@ -288,6 +288,23 @@
|
|
|
288
288
|
"react"
|
|
289
289
|
]
|
|
290
290
|
},
|
|
291
|
+
{
|
|
292
|
+
"path": "src/core/floating.ts",
|
|
293
|
+
"dest": "input/floating-core.ts",
|
|
294
|
+
"targets": [
|
|
295
|
+
"react"
|
|
296
|
+
]
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
"path": "src/react/floating.ts",
|
|
300
|
+
"dest": "input/floating.ts",
|
|
301
|
+
"targets": [
|
|
302
|
+
"react"
|
|
303
|
+
],
|
|
304
|
+
"rewrite": {
|
|
305
|
+
"@/core/floating": "./floating-core"
|
|
306
|
+
}
|
|
307
|
+
},
|
|
291
308
|
{
|
|
292
309
|
"path": "src/react/input/color.tsx",
|
|
293
310
|
"dest": "input/color.tsx",
|
|
@@ -299,7 +316,8 @@
|
|
|
299
316
|
"@/react/input/write-value": "./write-value",
|
|
300
317
|
"@/react/input/color-styles": "./color-styles",
|
|
301
318
|
"@/react/input/types": "./types",
|
|
302
|
-
"@/react/input/color-swatch": "./color-swatch"
|
|
319
|
+
"@/react/input/color-swatch": "./color-swatch",
|
|
320
|
+
"@/react/floating": "./floating"
|
|
303
321
|
}
|
|
304
322
|
},
|
|
305
323
|
{
|
|
@@ -748,6 +766,23 @@
|
|
|
748
766
|
"@/core/select": "./select"
|
|
749
767
|
}
|
|
750
768
|
},
|
|
769
|
+
{
|
|
770
|
+
"path": "src/core/floating.ts",
|
|
771
|
+
"dest": "select/floating-core.ts",
|
|
772
|
+
"targets": [
|
|
773
|
+
"react"
|
|
774
|
+
]
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
"path": "src/react/floating.ts",
|
|
778
|
+
"dest": "select/floating.ts",
|
|
779
|
+
"targets": [
|
|
780
|
+
"react"
|
|
781
|
+
],
|
|
782
|
+
"rewrite": {
|
|
783
|
+
"@/core/floating": "./floating-core"
|
|
784
|
+
}
|
|
785
|
+
},
|
|
751
786
|
{
|
|
752
787
|
"path": "src/react/select/root.tsx",
|
|
753
788
|
"dest": "select/root.tsx",
|
|
@@ -760,7 +795,8 @@
|
|
|
760
795
|
"@/core/search": "./search-core",
|
|
761
796
|
"@/react/slot": "./slot",
|
|
762
797
|
"@/react/select/styles": "./styles",
|
|
763
|
-
"@/react/select/context": "./context"
|
|
798
|
+
"@/react/select/context": "./context",
|
|
799
|
+
"@/react/floating": "./floating"
|
|
764
800
|
}
|
|
765
801
|
},
|
|
766
802
|
{
|
|
@@ -895,6 +931,23 @@
|
|
|
895
931
|
"@/core/context-menu": "./context-menu"
|
|
896
932
|
}
|
|
897
933
|
},
|
|
934
|
+
{
|
|
935
|
+
"path": "src/core/floating.ts",
|
|
936
|
+
"dest": "context-menu/floating-core.ts",
|
|
937
|
+
"targets": [
|
|
938
|
+
"react"
|
|
939
|
+
]
|
|
940
|
+
},
|
|
941
|
+
{
|
|
942
|
+
"path": "src/react/floating.ts",
|
|
943
|
+
"dest": "context-menu/floating.ts",
|
|
944
|
+
"targets": [
|
|
945
|
+
"react"
|
|
946
|
+
],
|
|
947
|
+
"rewrite": {
|
|
948
|
+
"@/core/floating": "./floating-core"
|
|
949
|
+
}
|
|
950
|
+
},
|
|
898
951
|
{
|
|
899
952
|
"path": "src/react/context-menu/root.tsx",
|
|
900
953
|
"dest": "context-menu/root.tsx",
|
|
@@ -908,7 +961,9 @@
|
|
|
908
961
|
"@/react/slot": "./slot",
|
|
909
962
|
"@/react/context-menu/styles": "./styles",
|
|
910
963
|
"@/react/context-menu/context": "./context",
|
|
911
|
-
"@/core/clipboard-menu": "./clipboard-menu"
|
|
964
|
+
"@/core/clipboard-menu": "./clipboard-menu",
|
|
965
|
+
"@/react/floating": "./floating",
|
|
966
|
+
"@/core/floating": "./floating-core"
|
|
912
967
|
}
|
|
913
968
|
},
|
|
914
969
|
{
|
|
@@ -1542,6 +1597,23 @@
|
|
|
1542
1597
|
"@/react/context-menu/styles": "./context-menu-styles"
|
|
1543
1598
|
}
|
|
1544
1599
|
},
|
|
1600
|
+
{
|
|
1601
|
+
"path": "src/core/floating.ts",
|
|
1602
|
+
"dest": "image/floating-core.ts",
|
|
1603
|
+
"targets": [
|
|
1604
|
+
"react"
|
|
1605
|
+
]
|
|
1606
|
+
},
|
|
1607
|
+
{
|
|
1608
|
+
"path": "src/react/floating.ts",
|
|
1609
|
+
"dest": "image/floating.ts",
|
|
1610
|
+
"targets": [
|
|
1611
|
+
"react"
|
|
1612
|
+
],
|
|
1613
|
+
"rewrite": {
|
|
1614
|
+
"@/core/floating": "./floating-core"
|
|
1615
|
+
}
|
|
1616
|
+
},
|
|
1545
1617
|
{
|
|
1546
1618
|
"path": "src/react/context-menu/root.tsx",
|
|
1547
1619
|
"dest": "image/root.tsx",
|
|
@@ -1555,7 +1627,9 @@
|
|
|
1555
1627
|
"@/react/slot": "./slot",
|
|
1556
1628
|
"@/react/context-menu/styles": "./context-menu-styles",
|
|
1557
1629
|
"@/react/context-menu/context": "./context",
|
|
1558
|
-
"@/core/clipboard-menu": "./clipboard-menu"
|
|
1630
|
+
"@/core/clipboard-menu": "./clipboard-menu",
|
|
1631
|
+
"@/react/floating": "./floating",
|
|
1632
|
+
"@/core/floating": "./floating-core"
|
|
1559
1633
|
}
|
|
1560
1634
|
},
|
|
1561
1635
|
{
|
|
@@ -1745,6 +1819,23 @@
|
|
|
1745
1819
|
"@/react/context-menu/styles": "./context-menu-styles"
|
|
1746
1820
|
}
|
|
1747
1821
|
},
|
|
1822
|
+
{
|
|
1823
|
+
"path": "src/core/floating.ts",
|
|
1824
|
+
"dest": "video/floating-core.ts",
|
|
1825
|
+
"targets": [
|
|
1826
|
+
"react"
|
|
1827
|
+
]
|
|
1828
|
+
},
|
|
1829
|
+
{
|
|
1830
|
+
"path": "src/react/floating.ts",
|
|
1831
|
+
"dest": "video/floating.ts",
|
|
1832
|
+
"targets": [
|
|
1833
|
+
"react"
|
|
1834
|
+
],
|
|
1835
|
+
"rewrite": {
|
|
1836
|
+
"@/core/floating": "./floating-core"
|
|
1837
|
+
}
|
|
1838
|
+
},
|
|
1748
1839
|
{
|
|
1749
1840
|
"path": "src/react/context-menu/root.tsx",
|
|
1750
1841
|
"dest": "video/root.tsx",
|
|
@@ -1758,7 +1849,9 @@
|
|
|
1758
1849
|
"@/react/slot": "./slot",
|
|
1759
1850
|
"@/react/context-menu/styles": "./context-menu-styles",
|
|
1760
1851
|
"@/react/context-menu/context": "./context",
|
|
1761
|
-
"@/core/clipboard-menu": "./clipboard-menu"
|
|
1852
|
+
"@/core/clipboard-menu": "./clipboard-menu",
|
|
1853
|
+
"@/react/floating": "./floating",
|
|
1854
|
+
"@/core/floating": "./floating-core"
|
|
1762
1855
|
}
|
|
1763
1856
|
},
|
|
1764
1857
|
{
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a floating panel goes: below or above the thing that opened it, inside the window.
|
|
3
|
+
*
|
|
4
|
+
* One function for every popup here - the select's list, the colour picker, the context menu
|
|
5
|
+
* - because they are the same question with different anchors. The select and the picker
|
|
6
|
+
* hang from a trigger's box; the context menu hangs from the pointer, which is the same box
|
|
7
|
+
* with no size. Pure arithmetic on rectangles, so it has no DOM and no framework: the React
|
|
8
|
+
* half that measures and applies it is `react/floating.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A box in viewport coordinates, the shape `getBoundingClientRect()` returns. */
|
|
12
|
+
export interface FloatingRect {
|
|
13
|
+
left: number;
|
|
14
|
+
top: number;
|
|
15
|
+
right: number;
|
|
16
|
+
bottom: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type FloatingSide = "top" | "bottom";
|
|
20
|
+
|
|
21
|
+
export interface FloatingOptions {
|
|
22
|
+
/** The area the panel has to stay inside - the window, or less when something clips it. */
|
|
23
|
+
boundary: FloatingRect;
|
|
24
|
+
/** Kept clear of the boundary's edge, so a panel never sits flush against it. */
|
|
25
|
+
margin?: number;
|
|
26
|
+
/** Between the anchor and the panel. */
|
|
27
|
+
gap?: number;
|
|
28
|
+
/** A side the caller insists on. `"auto"` (the default) measures. */
|
|
29
|
+
side?: FloatingSide | "auto";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FloatingPlacement {
|
|
33
|
+
left: number;
|
|
34
|
+
top: number;
|
|
35
|
+
side: FloatingSide;
|
|
36
|
+
/**
|
|
37
|
+
* The tallest the panel can be on that side without leaving the boundary. Applied as a
|
|
38
|
+
* `max-height`, so a list longer than the room scrolls inside the panel instead of
|
|
39
|
+
* running off the screen where its last rows cannot be reached.
|
|
40
|
+
*/
|
|
41
|
+
maxHeight: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Below when it fits, above when it does not and there is more room there.
|
|
46
|
+
*
|
|
47
|
+
* Flipped only when the other side is genuinely roomier: near the bottom of the window an
|
|
48
|
+
* unflipped panel hangs off the screen, but flipping a panel that fits neither way into the
|
|
49
|
+
* SMALLER side just moves the problem. Whichever side wins, `maxHeight` is the room it has.
|
|
50
|
+
*
|
|
51
|
+
* Horizontally it starts at the anchor's left edge and flips to end at its right edge when
|
|
52
|
+
* it would run past the window - flipped rather than dragged back, because a context menu
|
|
53
|
+
* dragged back to fit sits under the pointer and the release chooses its first row. The
|
|
54
|
+
* clamp after it only matters for a panel wider than the room on both sides.
|
|
55
|
+
*/
|
|
56
|
+
export function placeFloating(anchor: FloatingRect, size: { width: number; height: number; }, options: FloatingOptions): FloatingPlacement {
|
|
57
|
+
const { boundary, margin = 8, gap = 0, side = "auto" } = options;
|
|
58
|
+
const below = boundary.bottom - margin - anchor.bottom - gap;
|
|
59
|
+
const above = anchor.top - gap - boundary.top - margin;
|
|
60
|
+
const chosen: FloatingSide = side !== "auto" ? side : size.height <= below || below >= above ? "bottom" : "top";
|
|
61
|
+
const maxHeight = Math.max(0, Math.floor(chosen === "bottom" ? below : above));
|
|
62
|
+
const height = Math.min(size.height, maxHeight);
|
|
63
|
+
const top = chosen === "bottom" ? anchor.bottom + gap : anchor.top - gap - height;
|
|
64
|
+
|
|
65
|
+
const maxLeft = boundary.right - margin - size.width;
|
|
66
|
+
let left = anchor.left;
|
|
67
|
+
if (left > maxLeft) left = anchor.right - size.width;
|
|
68
|
+
left = Math.max(boundary.left + margin, Math.min(left, maxLeft));
|
|
69
|
+
|
|
70
|
+
return { left: Math.round(left), top: Math.round(top), side: chosen, maxHeight };
|
|
71
|
+
}
|
|
@@ -4,7 +4,9 @@ import { Slot } from "@/react/slot";
|
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import { shortenQuery } from "@/core/search";
|
|
6
6
|
import { shortcutTokens } from "@/core/keys";
|
|
7
|
+
import { placeFloating } from "@/core/floating";
|
|
7
8
|
import { CONTEXT_MENU_STYLES } from "@/react/context-menu/styles";
|
|
9
|
+
import { floatingContainer, measureFrame } from "@/react/floating";
|
|
8
10
|
import { ContextMenuContext, useContextMenuContext, type ContextMenuItem, type ContextMenuNode } from "@/react/context-menu/context";
|
|
9
11
|
import {
|
|
10
12
|
CLIPBOARD_PREFIX, clipboardEntries, clipboardAction, clipboardHasText, inspectClipboardTarget, performClipboardAction,
|
|
@@ -38,7 +40,9 @@ import {
|
|
|
38
40
|
* that is what a pointer event reports. Left in the tree it inherits any ancestor's
|
|
39
41
|
* `overflow: hidden`, any `transform` (which makes `fixed` resolve against that ancestor
|
|
40
42
|
* rather than the window) and any stacking context - so the menu ends up clipped by the row
|
|
41
|
-
* that opened it. Rendered into `<body>`, it is subject to none of them.
|
|
43
|
+
* that opened it. Rendered into `<body>`, it is subject to none of them. Inside a dialog it
|
|
44
|
+
* goes into the dialog instead, which a modal `<dialog>` makes the only live part of the
|
|
45
|
+
* page - the select and the colour picker share the rule, in `react/floating.ts`.
|
|
42
46
|
*/
|
|
43
47
|
|
|
44
48
|
/**
|
|
@@ -605,7 +609,7 @@ export function ContextMenuContent({ chunk = 40, portal = true, ...props }: Cont
|
|
|
605
609
|
|
|
606
610
|
if (!portal) return panels;
|
|
607
611
|
if (!mounted || typeof document === "undefined") return null;
|
|
608
|
-
return createPortal(panels, document.body);
|
|
612
|
+
return createPortal(panels, floatingContainer(menu.triggerRef.current) ?? document.body);
|
|
609
613
|
}
|
|
610
614
|
|
|
611
615
|
export interface ContextMenuPanelProps extends ComponentPropsWithoutRef<"div"> {
|
|
@@ -634,23 +638,31 @@ export function ContextMenuPanel({ level, chunk = 40, ...props }: ContextMenuPan
|
|
|
634
638
|
useLayoutEffect(() => {
|
|
635
639
|
const panel = ref.current;
|
|
636
640
|
if (!panel) return;
|
|
637
|
-
// Measured with the
|
|
638
|
-
//
|
|
641
|
+
// Measured with the cap cleared, so a panel that was shortened to fit last time is
|
|
642
|
+
// measured at its real height and not at the room it had then.
|
|
643
|
+
panel.style.maxHeight = "";
|
|
639
644
|
const width = panel.offsetWidth;
|
|
640
645
|
const height = panel.offsetHeight;
|
|
641
|
-
|
|
642
|
-
|
|
646
|
+
// The viewport, or the dialog it is portaled into when that dialog is what `fixed`
|
|
647
|
+
// resolves against - and the offset between the two, which every coordinate pays.
|
|
648
|
+
const frame = measureFrame(panel);
|
|
649
|
+
const { boundary } = frame;
|
|
643
650
|
|
|
644
651
|
let left: number;
|
|
645
652
|
let top: number;
|
|
646
653
|
|
|
647
654
|
if (level === 0) {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
//
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
655
|
+
// The pointer is the anchor: a box with no size. Flipped rather than clamped, by
|
|
656
|
+
// the shared placement - a menu whose left edge is dragged back to fit would sit
|
|
657
|
+
// UNDER the pointer, and the first row would be chosen by the release.
|
|
658
|
+
const x = point?.x ?? MARGIN;
|
|
659
|
+
const y = point?.y ?? MARGIN;
|
|
660
|
+
const placedAt = placeFloating({ left: x, right: x, top: y, bottom: y }, { width, height }, { boundary, margin: MARGIN });
|
|
661
|
+
left = placedAt.left;
|
|
662
|
+
top = placedAt.top;
|
|
663
|
+
// Capped to the room on the side it opened, with the list scrolling inside: in a
|
|
664
|
+
// short window the alternative is rows below the edge that nothing can reach.
|
|
665
|
+
panel.style.maxHeight = `${placedAt.maxHeight}px`;
|
|
654
666
|
} else {
|
|
655
667
|
const parent = document.getElementById(menu.panelId(level - 1))?.getBoundingClientRect();
|
|
656
668
|
const row = document.getElementById(menu.itemId(level - 1, menu.state.levels[level - 1]?.active ?? -1))?.getBoundingClientRect();
|
|
@@ -658,12 +670,15 @@ export function ContextMenuPanel({ level, chunk = 40, ...props }: ContextMenuPan
|
|
|
658
670
|
// Overlapped by a couple of pixels on purpose: a gap between a row and its
|
|
659
671
|
// submenu is a strip of page that closes the branch when the pointer crosses it.
|
|
660
672
|
left = anchor.right - 2;
|
|
661
|
-
if (left + width >
|
|
673
|
+
if (left + width > boundary.right - MARGIN) left = Math.max(boundary.left + MARGIN, anchor.left - width + 2);
|
|
662
674
|
top = (row?.top ?? anchor.top) - 4;
|
|
663
|
-
if (top + height >
|
|
675
|
+
if (top + height > boundary.bottom - MARGIN) top = Math.max(boundary.top + MARGIN, boundary.bottom - MARGIN - height);
|
|
664
676
|
}
|
|
665
677
|
|
|
666
|
-
setPlaced({
|
|
678
|
+
setPlaced({
|
|
679
|
+
left: Math.max(boundary.left + MARGIN, Math.round(left)) - frame.x,
|
|
680
|
+
top: Math.max(boundary.top + MARGIN, Math.round(top)) - frame.y
|
|
681
|
+
});
|
|
667
682
|
// Re-placed whenever the panel's own size can have changed: a filter that shortens the
|
|
668
683
|
// list, a fetched submenu that arrived, another chunk of a long one.
|
|
669
684
|
}, [level, point?.x, point?.y, rows, loading, menu, menu.state.levels.length]);
|
|
@@ -34,8 +34,11 @@ export const CONTEXT_MENU_STYLES = `
|
|
|
34
34
|
top: the image viewer's own three-dot menu was portalled to the same body and painted
|
|
35
35
|
underneath it, so its rows could be neither seen nor pressed. A menu is the topmost
|
|
36
36
|
transient surface on a page, and this is the number that says so. It belongs here and
|
|
37
|
-
not in the light-scheme block, or half the readers get an invalid z-index.
|
|
38
|
-
|
|
37
|
+
not in the light-scheme block, or half the readers get an invalid z-index.
|
|
38
|
+
--enigma-floating-z is the layer the select and the colour picker share, declared with
|
|
39
|
+
the same value in their sheets, so overriding it once moves every popup together. */
|
|
40
|
+
--enigma-floating-z: 10000;
|
|
41
|
+
--enigma-menu-z: var(--enigma-floating-z);
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
@media (prefers-color-scheme: light) {
|
|
@@ -57,6 +60,9 @@ export const CONTEXT_MENU_STYLES = `
|
|
|
57
60
|
ancestor cannot drag it away from the place it was opened. */
|
|
58
61
|
[data-enigma-menu-panel] {
|
|
59
62
|
position: fixed; z-index: var(--enigma-menu-z);
|
|
63
|
+
/* A column, so the max-height the placement writes in a short window shrinks the LIST,
|
|
64
|
+
which scrolls, rather than letting rows spill past the panel's edge. */
|
|
65
|
+
display: flex; flex-direction: column;
|
|
60
66
|
box-sizing: border-box;
|
|
61
67
|
min-width: var(--enigma-menu-min-width);
|
|
62
68
|
max-width: min(22rem, calc(100vw - 1rem));
|
|
@@ -111,6 +117,7 @@ export const CONTEXT_MENU_STYLES = `
|
|
|
111
117
|
|
|
112
118
|
[data-enigma-menu-list] {
|
|
113
119
|
max-height: var(--enigma-menu-max-height);
|
|
120
|
+
min-height: 0; flex: 0 1 auto;
|
|
114
121
|
overflow-y: auto; overscroll-behavior: contain;
|
|
115
122
|
}
|
|
116
123
|
|