UNPKG

@activecollab/components

Version:

ActiveCollab Components

824 lines (785 loc) 34.8 kB
import _extends from "@babel/runtime/helpers/esm/extends"; import React, { useMemo, useRef, useState } from "react"; import styled from "styled-components"; import { SELECTION_BLUE, StyledCanvasElement } from "./elements"; import { IconButton } from "../../components/IconButton"; import { ArrowLeftIcon, SortGeneralIcon } from "../../components/Icons"; import { Input } from "../../components/Input"; import { SkeletonLoader } from "../../components/Loaders"; import { Modal } from "../../components/Modal"; import { Paper } from "../../components/Paper"; import { RadioButton } from "../../components/RadioButton"; import { StackedCard } from "../../components/StackedCard"; import { Tooltip } from "../../components/Tooltip"; import { Body2, Caption1 } from "../../components/Typography"; import { AVATAR_COLORS } from "../shared"; import { INTERNAL_CLIENT_LABEL as CONTENT_INTERNAL_CLIENT_LABEL, ProjectCard, TaskCard } from "../stackedCard/content"; /* ------------------------------------------------------------------ */ /* Project elements — placeholder → picker → resolved card */ /* ------------------------------------------------------------------ */ /** * The board's third "draw a box, then say what goes in it" element, after the * link and the file card. The gesture ladder is deliberately the same one those * two already established, because it is the only one the dock teaches: * * armed the Project Elements dock button opens a submenu (Project / * Task); picking one arms the tool and puts the canvas in * crosshair mode. * drawn mouse down → drag → up leaves a dashed PLACEHOLDER at the drawn * box (clamped to a card-sized minimum), exactly where the real * card will sit. * picking the release opens the picker — a centred popover replicating the * app's `MenuSelector/ProjectList` (and, for tasks, the two-step * `ProjectTaskMenuSelector`: projects, then that project's tasks). * resolving a short skeleton flash in the placeholder's own box, standing in * for "the board is fetching this record's details". * resolved the placeholder becomes the real card, anchored at the * placeholder's top-left and grown to the card's natural size. * * Escape or a click on the overlay cancels: the picker closes and the * placeholder is thrown away, so a cancelled gesture leaves no debris. */ /* A drawn box smaller than this reads as "I just want a card here", so the placeholder takes the minimum rather than the (unusable) drawn size. */ export const PLACEHOLDER_MIN_WIDTH = 260; export const PLACEHOLDER_MIN_HEIGHT = 120; /* The natural width each resolved card grows to. Heights follow the content, like every other card on this board. */ export const PROJECT_CARD_WIDTH = 300; export const TASK_CARD_WIDTH = 260; /* How long the "details are being resolved from the backend" flash lasts. */ export const RESOLVE_MS = 480; /** True for the two board tools this module owns. */ export const isProjectTool = tool => tool === "project" || tool === "task"; /* ------------------------------------------------------------------ */ /* Mock data */ /* ------------------------------------------------------------------ */ /** * The people and labels the board carries are exactly what the shared content * cards read, so the picker's types ARE the cards' types — one shape, so a card * cannot be fed something it does not understand. */ /** * A project label. The app renders it as a FILLED pill whose background is the * label's own colour and whose text is a darker shade of it * (`ProjectLabel.jsx`: `EntityLabel` with `backgroundColor: label.color` and * `color: label.darker_text_color`), which is the design system's `Chip` — not * the colour-text `Tag` the column cards use. Both colours are data. The shape * is the shared `ProjectCard`'s own. */ /** * What an internal project's client line says. The word belongs to the card (it * is the card that has to decide what to print for a project with no client), so * the board re-exports the card's own constant rather than keeping a second copy * that could drift. */ export const INTERNAL_CLIENT_LABEL = CONTENT_INTERNAL_CLIENT_LABEL; /* One rule for both the card's client line and the picker's group header, so a project can never be filed under one name and labelled with another. */ export const projectClientLabel = project => { var _project$client; return (_project$client = project.client) != null ? _project$client : INTERNAL_CLIENT_LABEL; }; /* Avatar colours are DATA, not theme — they stand in for real user avatars. */ export const PICKER_PEOPLE = { jasmina: { name: "Jasmina Sekulić", initials: "JS", color: AVATAR_COLORS.purple }, marko: { name: "Marko Antić", initials: "MA", color: AVATAR_COLORS.teal }, dina: { name: "Dina Kovač", initials: "DK", color: AVATAR_COLORS.orange }, elena: { name: "Elena Petrović", initials: "EP", color: AVATAR_COLORS.yellow }, tomas: { name: "Tomas Slavik", initials: "TS", color: AVATAR_COLORS.blue } }; /* Label colours are data too: they are the label's own colour in the app. */ export const PICKER_LABELS = { assigned: { text: "ASSIGNED", color: "#E08B8B" }, blocked: { text: "BLOCKED", color: "#D9534F" }, inProgress: { text: "IN PROGRESS", color: "#9BA0A8" }, review: { text: "REVIEW", color: "#D4A017" } }; /* Project labels carry a background AND a darker text colour, the pair the app stores as `color` / `darker_text_color`. Uppercase because that is how the app saves a new project label (ProjectLabel.jsx upper-cases the name). */ export const PICKER_PROJECT_LABELS = { client: { text: "CLIENT WORK", color: "#FBBB75", textColor: "#6B4416" }, internal: { text: "INTERNAL", color: "#B7D8B0", textColor: "#2F5B28" }, urgent: { text: "URGENT", color: "#F3A7A7", textColor: "#7A2222" }, research: { text: "RESEARCH", color: "#B8CDEB", textColor: "#274974" } }; /* A compact constructor so every mocked task stays one readable row. */ const mockTask = function (number, name, taskList, extra) { if (extra === void 0) { extra = {}; } return _extends({ id: "task-" + number, number, name, taskList, assignees: [PICKER_PEOPLE.elena], date: "16. Jul" }, extra); }; export const PICKER_PROJECTS = [{ id: "p-ama", name: "AMA Webinar", client: "ActiveCollab Inc.", description: "Quarterly live session with the community. Collect questions up front, keep the run of show to forty minutes, and publish the recap the same week.", lead: PICKER_PEOPLE.jasmina, label: PICKER_PROJECT_LABELS.client, tasks: [mockTask(184, "Draft the run of show", "Preparation", { subtasks: 3 }), mockTask(185, "Collect questions from the community", "Preparation", { label: PICKER_LABELS.assigned, comments: 4 }), mockTask(186, "Record the intro segment", "Production", { assignees: [PICKER_PEOPLE.marko] }), mockTask(187, "Publish the recap post", "Follow-up", { label: PICKER_LABELS.review, date: "24. Jul" })] }, { id: "p-care", /* Internal project WITH a description and a leader, but no label. */ name: "Customer Care Team", client: null, description: "Where the support team plans its week. Macros, escalation paths and the weekly churn review all live here.", lead: PICKER_PEOPLE.dina, tasks: [mockTask(201, "Rewrite the macro replies", "Inbox", { label: PICKER_LABELS.inProgress, comments: 2 }), mockTask(202, "Weekly churn review", "Reporting", { subtasks: 5 }), mockTask(203, "Escalation path for billing", "Inbox", { assignees: [PICKER_PEOPLE.tomas] }), mockTask(204, "Onboarding call script", "Reporting", { date: "2. Aug" })] }, { id: "p-backlog", /* No description and NO leader — the meta row is the label alone, pushed to the right of an otherwise empty row. */ name: "Design Backlog", client: null, label: PICKER_PROJECT_LABELS.internal, tasks: [mockTask(311, "Whiteboard element inspector", "Ideas", { label: PICKER_LABELS.assigned, subtasks: 2, comments: 3 }), mockTask(312, "Empty states for the board list", "Ideas"), mockTask(313, "Dark theme audit of the dock", "Ready", { label: PICKER_LABELS.blocked, assignees: [PICKER_PEOPLE.jasmina, PICKER_PEOPLE.marko] }), mockTask(314, "Icon set for board elements", "Ready", { date: "9. Aug" })] }, { id: "p-design-team", /* Leader only: no description, no label. */ name: "Design Team", client: null, lead: PICKER_PEOPLE.elena, tasks: [mockTask(402, "Design system spring cleanup", "This Sprint", { subtasks: 8 }), mockTask(403, "Retro board template", "This Sprint", { label: PICKER_LABELS.inProgress, assignees: [PICKER_PEOPLE.dina] }), mockTask(404, "Handoff checklist", "Next Sprint", { comments: 1 }), mockTask(405, "Component audit: cards", "Next Sprint", { date: "30. Jul" })] }, { id: "p-foo", /* The bare minimum a project card can be: name and client line only. No description, no leader, no label — so no meta row at all. */ name: "Foo Kanban", client: null, tasks: [mockTask(77, "Set up the columns", "Task List", { subtasks: 1 }), mockTask(78, "Import the old cards", "Task List", { label: PICKER_LABELS.blocked }), mockTask(79, "Invite the team", "Task List 2", { assignees: [PICKER_PEOPLE.marko] }), mockTask(80, "Archive stale swimlanes", "Task List 2", { date: "5. Aug" })] }, { id: "p-marketing", name: "Marketing Site", client: "ActiveCollab Inc.", description: "Pricing, case studies and the homepage. Copy passes go through content, performance budgets through engineering.", lead: PICKER_PEOPLE.marko, label: PICKER_PROJECT_LABELS.urgent, tasks: [mockTask(510, "Pricing page copy pass", "Content", { label: PICKER_LABELS.review, comments: 6 }), mockTask(511, "Case study: Bright Lane", "Content", { subtasks: 4 }), mockTask(512, "Lighthouse budget for the homepage", "Engineering", { assignees: [PICKER_PEOPLE.tomas] }), mockTask(513, "Cookie banner rework", "Engineering", { date: "11. Aug" })] }, { id: "p-research", name: "Product Research", client: null, description: "Interviews, synthesis and concept tests for the board work. Findings are shared with design at the end of each round.", lead: PICKER_PEOPLE.jasmina, label: PICKER_PROJECT_LABELS.research, tasks: [mockTask(620, "Interview script for board users", "Discovery", { subtasks: 3 }), mockTask(621, "Synthesis of the June calls", "Discovery", { label: PICKER_LABELS.inProgress, comments: 2 }), mockTask(622, "Concept test: project cards", "Validation", { assignees: [PICKER_PEOPLE.elena, PICKER_PEOPLE.dina] }), mockTask(623, "Share findings with design", "Validation", { date: "19. Aug" })] }, { id: "p-brand", name: "Brand Refresh", client: "Bright Lane Studio", description: "Identity work: logo lockups, a typography scale and the photography direction, collected into a brand book.", lead: PICKER_PEOPLE.dina, label: PICKER_PROJECT_LABELS.client, tasks: [mockTask(701, "Logo lockup variations", "Identity", { subtasks: 6 }), mockTask(702, "Typography scale", "Identity", { label: PICKER_LABELS.assigned }), mockTask(703, "Photography direction", "Guidelines", { comments: 3 }), mockTask(704, "Brand book layout", "Guidelines", { date: "28. Jul" })] }, { id: "p-portal", /* Leader AND label, but no description. */ name: "Client Portal", client: "Bright Lane Studio", lead: PICKER_PEOPLE.tomas, label: PICKER_PROJECT_LABELS.urgent, tasks: [mockTask(810, "Invoice list pagination", "Backend", { label: PICKER_LABELS.blocked, subtasks: 2 }), mockTask(811, "Single sign-on for clients", "Backend", { comments: 5 }), mockTask(812, "Dashboard tiles", "Frontend", { assignees: [PICKER_PEOPLE.jasmina] }), mockTask(813, "Mobile navigation", "Frontend", { date: "3. Sep" })] }]; /* The picker groups projects by client and tasks by task list, exactly the way `ProjectList` / `TaskList` group them in the app. Order is the data's own order — the first project of a client decides where its group sits. Internal projects group under the same word their card shows, so the header and the card cannot say different things about the same project. */ export const groupProjects = projects => projects.reduce((groups, project) => { const client = projectClientLabel(project); const group = groups.find(entry => entry.client === client); if (group) { group.projects.push(project); return groups; } return [...groups, { client, projects: [project] }]; }, []); export const groupTasks = tasks => tasks.reduce((groups, task) => { const group = groups.find(entry => entry.taskList === task.taskList); if (group) { group.tasks.push(task); return groups; } return [...groups, { taskList: task.taskList, tasks: [task] }]; }, []); const matchesQuery = (value, query) => value.toLowerCase().includes(query.trim().toLowerCase()); /** `#123: Name` — `utils/taskDisplayName`, the app's own task display name. */ export const taskDisplayName = task => "#" + task.number + ": " + task.name; /* ------------------------------------------------------------------ */ /* Limited access */ /* ------------------------------------------------------------------ */ /** * A board is shared with people who do not all see the same projects. When a * card points at a task or project the current viewer cannot open, the board * cannot just drop it: the element is part of everyone else's layout, and a * hole would be a worse lie than a blank. So the card stays and says exactly * one thing — something is here, and it is not yours to see. * * This follows the app's existing limited-access language (Workload): * - the wording is "Limited Access", the phrase `EntityPlaceholder`, * `WorkloadCalendarEvent`, Expenses and Timelist all already use; * - nothing else is rendered. Workload's own limited cell * (`.workload_null_project_cell`) shows an aggregate and no name, and the * shape it is fed is literally `{ hoursOccupied, leftOffset }` — a quantity * and a position, never an identity; * - the surface is muted and hatched, the treatment the app reserves for * "this area is not live" (weekend / day-off cells, `--muted-surface`); * - no affordance suggests opening: Workload points the link at "#", disables * the name tooltip so the title cannot leak, and marks blocked interactions * `cursor: not-allowed`. */ export const LIMITED_ACCESS_LABEL = "Limited Access"; /* The one sentence a limited card is allowed to say, in the app's own voice. It states the viewer's situation as a fact and stops — Workload's secondary line is "with your limited access" (WorkloadProjectContainer.js), and its status sentences read "Work scheduled for a day off." and "task doesn't have an estimate": second person, sentence case, contraction, no apology, and never a word about why the thing is on the screen. */ export const LIMITED_ACCESS_MESSAGE = "You don't have access to this item."; /* ------------------------------------------------------------------ */ /* The element on the board */ /* ------------------------------------------------------------------ */ /** A project / task element on the board. Position lives in `positions`. */ /** The natural width a resolved card of this kind grows to. */ export const projectCardWidth = kind => kind === "project" ? PROJECT_CARD_WIDTH : TASK_CARD_WIDTH; /** * What the card is OF — the task's or the project's own name. * * These cards are a view of a record that lives in the project, so the board * does not get to name them: the title is the entity's, and it is read-only * wherever it appears. A placeholder has no entity yet, so it says what it is * still waiting for. */ export const projectElementTitle = element => { if (element.limited) { return LIMITED_ACCESS_LABEL; } if (element.kind === "project") { var _element$project$name, _element$project; return (_element$project$name = (_element$project = element.project) == null ? void 0 : _element$project.name) != null ? _element$project$name : "Project"; } return element.task ? taskDisplayName(element.task) : "Task"; }; /* ------------------------------------------------------------------ */ /* Seeded board state */ /* ------------------------------------------------------------------ */ /** * Two cards the board starts with, both limited. * * They are seeded rather than reachable through the picker on purpose: limited * access is a property of the VIEWER, and you cannot pick a card you are not * allowed to see. Someone else placed these; this session is simply the one * that cannot open them. Seeding is also the whole demonstration — no "view as" * switch is needed to show what the state looks like. * * Both are `resolved` with `project` and `task` left null: the record was never * withheld pending a fetch, it is withheld full stop. */ const SEEDED = [{ element: { id: "limited-task", kind: "task", width: TASK_CARD_WIDTH, height: PLACEHOLDER_MIN_HEIGHT, status: "resolved", limited: true, project: null, task: null }, x: 120, y: 200 }, { element: { id: "limited-project", kind: "project", width: PROJECT_CARD_WIDTH, height: PLACEHOLDER_MIN_HEIGHT, status: "resolved", limited: true, project: null, task: null }, x: 120, y: 380 }]; /* Derived from one list, the way INITIAL_POSITIONS is derived from CANVAS_ELEMENTS — a seed's position is written once, not twice. */ export const SEEDED_PROJECT_ELEMENTS = SEEDED.map(seed => seed.element); export const SEEDED_PROJECT_ELEMENT_POSITIONS = SEEDED.reduce((acc, seed) => { acc[seed.element.id] = { x: seed.x, y: seed.y }; return acc; }, {}); /* The board wrapper. It is the canvas element box, only rounded — the shared box is square because shapes are, and a square selection ring around a rounded card reads as a second, wrong border. The transition is what makes the placeholder GROW into its card instead of being replaced by one. */ export const StyledProjectElementBox = styled(StyledCanvasElement).withConfig({ displayName: "projectElements__StyledProjectElementBox", componentId: "sc-1cayxi5-0" })(["border-radius:8px;transition:width 160ms ease;&.limited{cursor:not-allowed;}"]); /* The rubber band and the armed tool's hover preview — the same blue dashed box the link tool draws, so the gesture reads as one board convention. */ export const StyledProjectDraft = styled.div.withConfig({ displayName: "projectElements__StyledProjectDraft", componentId: "sc-1cayxi5-1" })(["position:absolute;box-sizing:border-box;border:1.5px dashed ", ";border-radius:8px;background-color:rgba(59,130,246,0.08);pointer-events:none;"], SELECTION_BLUE); /* The placeholder that waits for the picker's answer. Card-like on purpose: rounded, dashed, tinted — a card-shaped hole, not a selection marquee. */ export const StyledPlaceholder = styled.div.withConfig({ displayName: "projectElements__StyledPlaceholder", componentId: "sc-1cayxi5-2" })(["box-sizing:border-box;width:100%;height:100%;display:flex;align-items:center;justify-content:center;padding:12px;border:1.5px dashed var(--border-primary);border-radius:8px;background-color:var(--color-theme-200);user-select:none;"]); /* The resolving flash: the placeholder's box, with the card's rows sketched in so the swap to the real card does not read as a different element. */ export const StyledResolving = styled(StyledPlaceholder).withConfig({ displayName: "projectElements__StyledResolving", componentId: "sc-1cayxi5-3" })(["flex-direction:column;align-items:stretch;justify-content:center;gap:10px;.c-loader{height:12px;}"]); /** * The limited-access face — the card-shaped translation of Workload's black * `.workload_null_project_cell`. * * The app hardcodes that cell `#000000` on `#ffffff` text, which works there * because it always sits on light paper. A canvas is not always light: on the * dark board a black slab would sink into the background instead of reading as * something withheld. So the same idea is expressed with the design system's * own "not live" tokens — `--muted-surface` / `--muted-surface-text` are the * pair the app itself substitutes for muted surfaces in dark themes, and they * are a legible, deliberately flat combination in every theme. * * The -45° hatch is copied stroke for stroke from the app's unavailable * surfaces (weekend and day-off cells): `transparent 5px, <line> 5px, <line> * 5.7px`, with `--stripe-pattern` as the line — the token workload.less uses * for exactly this in dark themes. */ export const StyledLimitedCard = styled.div.withConfig({ displayName: "projectElements__StyledLimitedCard", componentId: "sc-1cayxi5-4" })(["box-sizing:border-box;width:100%;height:100%;min-height:96px;display:flex;align-items:center;justify-content:center;padding:12px;border:1px solid var(--border-primary);border-radius:8px;background-color:var(--muted-surface);background-image:repeating-linear-gradient( -45deg,transparent,transparent 5px,var(--stripe-pattern) 5px,var(--stripe-pattern) 5.7px );color:var(--muted-surface-text);cursor:not-allowed;user-select:none;.limited-label{color:var(--muted-surface-text);}"]); /** * Both resolved faces are the design system's `StackedCard` — the same shell the * board's link and file cards already stand on — filled with the shared typed * content cards from `../stackedCard/content`. The board owns only the width and * the selection state; everything inside the card is the card's business. * * Identical to `StyledLinkCard`: the shell's reference 260px width gives way to * the box the board drew, and the height stays auto. */ const StyledEntityCard = styled(StackedCard).withConfig({ displayName: "projectElements__StyledEntityCard", componentId: "sc-1cayxi5-5" })(["&&{width:100%;}user-select:none;"]); /** * The task face — the shared `TaskCard`, with the project line switched on. * * The board writes no anatomy of its own: the title clamp, the info line (labels * as colour text, the counter signifiers and their 2px dot separators) and the * date / assignees row all live in the content card, where the column-view * baseline they preserve is documented. The one thing the board asks for is the * project line above the title: on a canvas a task card has lost the column that * used to say which project it came from. * * The title is the app's own display name (`#123: Name`, `taskDisplayName`), so * the card's separate task-number prefix stays off. */ export const TaskElementCard = _ref => { var _task$subtasks, _task$comments, _task$date; let project = _ref.project, task = _ref.task, selected = _ref.selected; return /*#__PURE__*/React.createElement(StyledEntityCard, { selected: selected }, /*#__PURE__*/React.createElement(TaskCard, { title: taskDisplayName(task), showProjectName: true, projectName: project.name, labels: task.label ? [task.label] : [], subtaskCount: (_task$subtasks = task.subtasks) != null ? _task$subtasks : 0, commentCount: (_task$comments = task.comments) != null ? _task$comments : 0, dateLabel: (_task$date = task.date) != null ? _task$date : null, assigned: task.assignees.length > 0, assignees: task.assignees })); }; /** * The project face — the shared `ProjectCard`. * * The whiteboard's face spec and the content type's are the SAME spec, so it is * implemented once, in the card: name and client, an optional description * excerpt, and one row carrying the leader on the left and the label on the * right. No progress indicator, and no row at all when the project has neither a * leader nor a label. */ export const ProjectElementCard = _ref2 => { let project = _ref2.project, selected = _ref2.selected; return /*#__PURE__*/React.createElement(StyledEntityCard, { selected: selected }, /*#__PURE__*/React.createElement(ProjectCard, { name: project.name, client: project.client, description: project.description, leader: project.lead, label: project.label })); }; /** * The limited-access card. Deliberately the shortest component in this file: * one phrase, no icon, no entity data of any kind. * * No icon because the app uses none — Expenses and Timelist both render the * bare `Caption1 weight="bold"` "Limited Access", and an icon here would be a * second signal competing with the hatch. Even the KIND (task vs project) is * withheld: the viewer is told that a card exists, not what sort of thing it * points at. * * The tooltip is allowed to exist because it carries the viewer's situation, * not the entity's identity — the opposite of the tooltip Workload disables on * a limited event, which would have leaked the name. */ export const LimitedElementCard = () => /*#__PURE__*/React.createElement(Tooltip, { title: LIMITED_ACCESS_MESSAGE }, /*#__PURE__*/React.createElement(StyledLimitedCard, { "data-testid": "wb-limited-card" }, /*#__PURE__*/React.createElement(Caption1, { weight: "bold", className: "limited-label" }, LIMITED_ACCESS_LABEL))); /** One board element, in whichever of its three states it currently is. */ export const ProjectElementNode = _ref3 => { let element = _ref3.element, selected = _ref3.selected; /* Checked before anything else: a limited element must never be able to fall through to a branch that renders entity data. */ if (element.limited) { return /*#__PURE__*/React.createElement(LimitedElementCard, null); } if (element.status === "placeholder") { return /*#__PURE__*/React.createElement(StyledPlaceholder, null, /*#__PURE__*/React.createElement(Caption1, { color: "tertiary" }, element.kind === "project" ? "Pick a project…" : "Pick a task…")); } if (element.status === "resolving") { return /*#__PURE__*/React.createElement(StyledResolving, null, /*#__PURE__*/React.createElement(SkeletonLoader, { style: { width: "80%" } }), /*#__PURE__*/React.createElement(SkeletonLoader, { style: { width: "55%" } }), /*#__PURE__*/React.createElement(SkeletonLoader, { style: { width: "40%" } })); } if (element.kind === "project" && element.project) { return /*#__PURE__*/React.createElement(ProjectElementCard, { project: element.project, selected: selected }); } if (element.project && element.task) { return /*#__PURE__*/React.createElement(TaskElementCard, { project: element.project, task: element.task, selected: selected }); } return /*#__PURE__*/React.createElement(StyledPlaceholder, null); }; /* ------------------------------------------------------------------ */ /* The picker */ /* ------------------------------------------------------------------ */ /* The popover, centred over the board's own overlay. It is deliberately NOT a DS `Dialog`: the app's picker has no title and no action buttons — you search, you pick, it closes. `Modal` is used only for what a dialog and a popover do share: the portal, the overlay, the focus lock and the Esc handler. */ const StyledPickerPanel = styled(Paper).withConfig({ displayName: "projectElements__StyledPickerPanel", componentId: "sc-1cayxi5-6" })(["position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);display:flex;flex-direction:column;width:470px;max-width:calc(100vw - 32px);max-height:70vh;overflow:hidden;&&{border-radius:12px;border:1px solid var(--border-primary);box-shadow:0 18px 48px rgba(0,0,0,0.24);}"]); /* Search + adornments, mirroring `Select`'s own form row: the back arrow is a start adornment (TaskList), the group-by sort a end adornment (ProjectList). */ const StyledPickerHeader = styled.div.withConfig({ displayName: "projectElements__StyledPickerHeader", componentId: "sc-1cayxi5-7" })(["display:flex;align-items:center;gap:8px;flex-shrink:0;padding:12px 12px 8px;.picker-search{width:100%;border-radius:8px;}.picker-search:focus-within{border-color:var(--color-primary);outline:1px solid var(--color-primary);}"]); const StyledPickerList = styled.div.withConfig({ displayName: "projectElements__StyledPickerList", componentId: "sc-1cayxi5-8" })(["flex:1 1 auto;min-height:0;overflow-y:auto;padding:4px 0 12px;.picker-group-header{box-sizing:border-box;height:36px;display:flex;align-items:center;padding:0 20px;margin-top:4px;}.picker-empty{padding:24px 20px;text-align:center;}"]); /* A generously spaced row: the name on the left, an empty radio on the right — the shape `Select` gives a single-choice option, at picker scale. */ const StyledPickerRow = styled.div.withConfig({ displayName: "projectElements__StyledPickerRow", componentId: "sc-1cayxi5-9" })(["box-sizing:border-box;height:56px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 20px;cursor:pointer;outline:none;&:hover,&:focus-visible{background-color:var(--color-theme-200);}.row-name{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;}.c-radio-btn{flex-shrink:0;}"]); const PickerRow = _ref4 => { let id = _ref4.id, name = _ref4.name, onSelect = _ref4.onSelect; return /*#__PURE__*/React.createElement(StyledPickerRow, { role: "button", tabIndex: 0, onClick: onSelect, onKeyDown: event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onSelect(); } } }, /*#__PURE__*/React.createElement("span", { className: "row-name" }, /*#__PURE__*/React.createElement(Body2, null, name)), /*#__PURE__*/React.createElement(RadioButton, { id: "wb-picker-" + id, tabIndex: -1 })); }; /** * One picker, two flows — the same split the app makes between * `MenuSelector/ProjectList` (one step) and `ProjectTaskMenuSelector` (project, * then task). The task flow deliberately drops the app's "Directly on project" * default option: a task card has to be a task. */ export const ProjectElementPicker = _ref5 => { let kind = _ref5.kind, onSelectProject = _ref5.onSelectProject, onSelectTask = _ref5.onSelectTask, onCancel = _ref5.onCancel; const _useState = useState(null), project = _useState[0], setProject = _useState[1]; const _useState2 = useState(""), query = _useState2[0], setQuery = _useState2[1]; /* The picker opens on a mouseUP — the end of the draw gesture — so the click that closes that gesture arrives when the overlay is already on screen. A dismissal therefore has to be a WHOLE click on the overlay: press, then release. Without this the placeholder could be cancelled by the very gesture that created it. */ const pressedInsideRef = useRef(false); const onTaskStep = kind === "task" && project !== null; const projectGroups = useMemo(() => groupProjects(PICKER_PROJECTS.filter(entry => matchesQuery(entry.name, query))), [query]); const taskGroups = useMemo(() => project ? groupTasks(project.tasks.filter(task => matchesQuery(taskDisplayName(task), query))) : [], [project, query]); const handleProject = picked => { if (kind === "project") { onSelectProject(picked); return; } setProject(picked); setQuery(""); }; const handleBack = () => { setProject(null); setQuery(""); }; const isEmpty = onTaskStep ? taskGroups.length === 0 : projectGroups.length === 0; return /*#__PURE__*/React.createElement(Modal, { open: true, onClose: onCancel, disableBackgroundClick: true, onMouseDown: () => { pressedInsideRef.current = true; }, onClickOutside: () => { if (pressedInsideRef.current) { onCancel(); } } }, /*#__PURE__*/React.createElement(StyledPickerPanel, { type: "paper-2" }, /*#__PURE__*/React.createElement(StyledPickerHeader, null, onTaskStep ? /*#__PURE__*/React.createElement(Tooltip, { title: "Back to projects" }, /*#__PURE__*/React.createElement(IconButton, { type: "button", variant: "text gray", onClick: handleBack, "aria-label": "Back to projects" }, /*#__PURE__*/React.createElement(ArrowLeftIcon, null))) : null, /*#__PURE__*/React.createElement(Input, { className: "picker-search", autoFocus: true, value: query, placeholder: onTaskStep ? "Search a task" : "Search a project", onChange: event => setQuery(event.target.value) }), onTaskStep ? null : /*#__PURE__*/React.createElement(Tooltip, { title: "Group by" }, /*#__PURE__*/React.createElement(IconButton, { type: "button", variant: "text gray" }, /*#__PURE__*/React.createElement(SortGeneralIcon, null)))), /*#__PURE__*/React.createElement(StyledPickerList, null, isEmpty ? /*#__PURE__*/React.createElement("div", { className: "picker-empty" }, /*#__PURE__*/React.createElement(Body2, { color: "tertiary" }, onTaskStep ? "No Result" : "No Projects")) : null, onTaskStep && project ? taskGroups.map(group => /*#__PURE__*/React.createElement("div", { key: group.taskList }, /*#__PURE__*/React.createElement("div", { className: "picker-group-header" }, /*#__PURE__*/React.createElement(Body2, { weight: "bold" }, group.taskList)), group.tasks.map(task => /*#__PURE__*/React.createElement(PickerRow, { key: task.id, id: task.id, name: taskDisplayName(task), onSelect: () => onSelectTask(project, task) })))) : null, onTaskStep ? null : projectGroups.map(group => /*#__PURE__*/React.createElement("div", { key: group.client }, /*#__PURE__*/React.createElement("div", { className: "picker-group-header" }, /*#__PURE__*/React.createElement(Body2, { weight: "bold" }, group.client)), group.projects.map(entry => /*#__PURE__*/React.createElement(PickerRow, { key: entry.id, id: entry.id, name: entry.name, onSelect: () => handleProject(entry) }))))))); }; //# sourceMappingURL=projectElements.js.map